diff --git a/src/app/shared/components/recipe-comments-list/recipe-comments-list.html b/src/app/shared/components/recipe-comments-list/recipe-comments-list.html index c6d3085..8e8b000 100644 --- a/src/app/shared/components/recipe-comments-list/recipe-comments-list.html +++ b/src/app/shared/components/recipe-comments-list/recipe-comments-list.html @@ -11,41 +11,40 @@ } @else {

You must be logged in to comment.

} -

Comments

- @if (commentsQuery.isPending()) { -

Loading comments...

- } @else if (commentsQuery.isError()) { -

There was an error loading the comments.

- } @else { - +
+ @if (hasNextSlice()) { + + } @else { +

No more comments.

+ } +
diff --git a/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts b/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts index 08cc76c..d9aa5b5 100644 --- a/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts +++ b/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts @@ -1,19 +1,20 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, computed, inject, input, OnInit, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { RecipeService } from '../../services/RecipeService'; -import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental'; import { AuthService } from '../../services/AuthService'; import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe'; import { SliceView } from '../../models/SliceView.model'; import { RecipeComment } from '../../models/RecipeComment.model'; +import { Spinner } from '../spinner/spinner'; +import { range, switchMap, toArray } from 'rxjs'; @Component({ selector: 'app-recipe-comments-list', - imports: [ReactiveFormsModule, DateTimeFormatPipe], + imports: [ReactiveFormsModule, DateTimeFormatPipe, Spinner], templateUrl: './recipe-comments-list.html', styleUrl: './recipe-comments-list.css', }) -export class RecipeCommentsList { +export class RecipeCommentsList implements OnInit { public readonly recipeUsername = input.required(); public readonly recipeSlug = input.required(); @@ -23,14 +24,32 @@ export class RecipeCommentsList { protected readonly username = this.authService.username; protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); - protected commentsQuery = injectInfiniteQuery(() => ({ - initialPageParam: 0, - getNextPageParam: (previousPage: SliceView) => - previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined, - queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()], - queryFn: ({ pageParam }) => { - return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), { - page: pageParam, + protected readonly totalComments = signal(0); + + protected readonly loadingComments = signal(false); + protected readonly loadCommentsError = signal(null); + protected readonly commentsSlices = signal[]>([]); + protected readonly hasNextSlice = computed(() => + this.commentsSlices().length ? this.commentsSlices()[this.commentsSlices().length - 1].slice.hasNext : null, + ); + protected readonly loadedCommentsCount = computed(() => + this.commentsSlices().reduce((acc, slice) => acc + slice.content.length, 0), + ); + + protected readonly addCommentForm = new FormGroup({ + comment: new FormControl('', Validators.required), + }); + + protected readonly submittingComment = signal(false); + protected readonly submitCommentError = signal(null); + protected readonly submittedComment = signal(''); + + public ngOnInit(): void { + this.loadingComments.set(true); + this.loadCommentCount(); + this.recipeService + .getComments(this.recipeUsername(), this.recipeSlug(), { + page: 0, size: 10, sort: [ { @@ -38,20 +57,100 @@ export class RecipeCommentsList { order: 'DESC', }, ], + }) + .subscribe({ + next: (commentsSlice) => { + this.loadingComments.set(false); + this.commentsSlices.update((old) => [...old, commentsSlice]); + }, + error: (e) => { + this.loadingComments.set(false); + this.loadCommentsError.set(e); + console.error(e); + }, }); - }, - })); + } - protected readonly addCommentForm = new FormGroup({ - comment: new FormControl('', Validators.required), - }); + private loadCommentCount(): void { + this.recipeService.getCommentsCount(this.recipeUsername(), this.recipeSlug()).subscribe({ + next: (count) => { + this.totalComments.set(count); + }, + error: (e) => { + console.error(e); + }, + }); + } - protected readonly addCommentMutation = injectMutation(() => ({ - mutationFn: (commentText: string) => - this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText), - })); + protected loadMoreComments(): void { + if (this.hasNextSlice()) { + this.loadingComments.set(true); + this.recipeService + .getComments(this.recipeUsername(), this.recipeSlug(), { + page: this.commentsSlices().length, + size: 10, + sort: [ + { + property: 'created', + order: 'DESC', + }, + ], + }) + .subscribe({ + next: (nextSlice) => { + this.loadingComments.set(false); + this.commentsSlices.update((prev) => [...prev, nextSlice]); + }, + error: (e) => { + this.loadingComments.set(false); + this.loadCommentsError.set(e); + console.error(e); + }, + }); + } + } + + private reloadComments(): void { + this.loadCommentCount(); + range(0, this.commentsSlices().length) + .pipe( + switchMap((page) => { + return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), { + page, + size: 10, + sort: [ + { + property: 'created', + order: 'DESC', + }, + ], + }); + }), + toArray(), + ) + .subscribe({ + next: (slices) => { + this.commentsSlices.set(slices); + }, + error: (e) => { + console.error(e); + }, + }); + } protected onCommentSubmit() { - this.addCommentMutation.mutate(this.addCommentForm.value.comment!); + const comment = this.addCommentForm.value.comment!; + this.submittingComment.set(true); + this.submittedComment.set(comment); + this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), comment).subscribe({ + next: () => { + this.submittingComment.set(false); + this.reloadComments(); + }, + error: (e) => { + this.submitCommentError.set(e); + console.error(e); + }, + }); } } diff --git a/src/app/shared/services/RecipeService.ts b/src/app/shared/services/RecipeService.ts index 30cf7be..5e1b633 100644 --- a/src/app/shared/services/RecipeService.ts +++ b/src/app/shared/services/RecipeService.ts @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { firstValueFrom, lastValueFrom, map, Observable } from 'rxjs'; +import { lastValueFrom, map, Observable } from 'rxjs'; import { FullRecipeView, FullRecipeViewWrapper, RecipeInfoView } from '../models/Recipe.model'; import { AuthService } from './AuthService'; import { QueryClient } from '@tanstack/angular-query-experimental'; @@ -27,6 +27,8 @@ export class RecipeService { 'totalTime', ] as const; + public static readonly RecipeCommentProperties = ['id', 'created', 'modified'] as const; + private readonly http = inject(HttpClient); private readonly authService = inject(AuthService); private readonly queryClient = inject(QueryClient); @@ -123,24 +125,26 @@ export class RecipeService { } } - public getComments(username: string, slug: string, queryParams?: QueryParams): Promise> { - return firstValueFrom( - this.http.get>( - this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams), - ), + public getComments( + username: string, + slug: string, + queryParams?: QueryParams, + ): Observable> { + return this.http.get>( + this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams), ); } - public async addComment(username: string, slug: string, commentText: string): Promise { - const comment = await firstValueFrom( - this.http.post(this.endpointService.getUrl('recipes', [username, slug, 'comments']), { - text: commentText, - }), - ); - await this.queryClient.invalidateQueries({ - queryKey: ['recipeComments', username, slug], + public getCommentsCount(username: string, slug: string): Observable { + return this.http + .get<{ count: number }>(this.endpointService.getUrl('recipes', [username, slug, 'comments', 'count'])) + .pipe(map((res) => res.count)); + } + + public addComment(username: string, slug: string, commentText: string): Observable { + return this.http.post(this.endpointService.getUrl('recipes', [username, slug, 'comments']), { + text: commentText, }); - return comment; } public aiSearch(prompt: string): Observable {