diff --git a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html index 168d732..71be4b8 100644 --- a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html +++ b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html @@ -30,17 +30,18 @@ } - @if (mainImageUrl.isSuccess()) { - @let maybeMainImageUrl = mainImageUrl.data(); - @if (!!maybeMainImageUrl) { - - } + @if (mainImageQuery.isLoading()) { + + } @else if (mainImageQuery.isError()) { +

There was an error loading the main image.

+ } @else if (mainImageQuery.isEnabled()) { + }
diff --git a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts index d672f59..2f7ed2c 100644 --- a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts +++ b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts @@ -1,6 +1,6 @@ import { Component, computed, inject, input } from '@angular/core'; import { RecipeView } from '../../../shared/models/Recipe.model'; -import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; +import { CreateQueryOptions, injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; import { ImageService } from '../../../shared/services/ImageService'; import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -8,10 +8,12 @@ import { RecipeService } from '../../../shared/services/RecipeService'; import { AuthService } from '../../../shared/services/AuthService'; import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list'; import { MatButton } from '@angular/material/button'; +import { Spinner } from '../../../shared/components/spinner/spinner'; +import { ImageViewWithBlobUrl } from '../../../shared/client-models/ImageViewWithBlobUrl'; @Component({ selector: 'app-recipe-page-content', - imports: [FaIconComponent, RecipeCommentsList, MatButton], + imports: [FaIconComponent, RecipeCommentsList, MatButton, Spinner], templateUrl: './recipe-page-content.html', styleUrl: './recipe-page-content.css', }) @@ -24,12 +26,22 @@ export class RecipePageContent { protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); - protected readonly mainImageUrl = injectQuery(() => { - const recipe = this.recipeView().recipe; - return { - queryKey: ['recipe-main-images', recipe.owner.username, recipe.slug], - queryFn: () => this.imageService.getImage(recipe.mainImage?.url), - }; + protected readonly mainImageQuery = injectQuery(() => { + let options: Partial< + CreateQueryOptions + > = {}; + const mainImageView = this.recipeView().recipe.mainImage; + if (mainImageView) { + options = this.imageService.getImage(mainImageView); + } else { + options.enabled = false; + } + return options as CreateQueryOptions< + ImageViewWithBlobUrl, + Error, + ImageViewWithBlobUrl, + [string, string, string] + >; }); protected readonly starMutation = injectMutation(() => ({ diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/edit-image-dialog/edit-image-dialog.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/edit-image-dialog/edit-image-dialog.ts index 6180435..ac94769 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/edit-image-dialog/edit-image-dialog.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/edit-image-dialog/edit-image-dialog.ts @@ -1,4 +1,4 @@ -import { Component, inject, input, OnInit } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { DialogContainer } from '../../../../../../shared/components/dialog-container/dialog-container'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -6,7 +6,6 @@ import { ImageView } from '../../../../../../shared/models/ImageView.model'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatButton } from '@angular/material/button'; import { ImageService } from '../../../../../../shared/services/ImageService'; -import { QueryClient } from '@tanstack/angular-query-experimental'; @Component({ selector: 'app-edit-image-dialog', @@ -17,7 +16,6 @@ import { QueryClient } from '@tanstack/angular-query-experimental'; export class EditImageDialog implements OnInit { protected readonly imageView: ImageView = inject(MAT_DIALOG_DATA); private readonly imageService = inject(ImageService); - private readonly queryClient = inject(QueryClient); private readonly dialogRef = inject(MatDialogRef); protected readonly imageForm = new FormGroup({ @@ -46,9 +44,6 @@ export class EditImageDialog implements OnInit { alt: this.imageForm.value.alt, caption: this.imageForm.value.caption, }); - await this.queryClient.invalidateQueries({ - queryKey: ['image-views'], - }); this.dialogRef.close(); } } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css index 9b65a85..9a9f63d 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css @@ -10,7 +10,8 @@ object-fit: cover; } -mat-card-content p { +.image-filename { + font-size: 0.75em; overflow-wrap: anywhere; } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html index 7bb923b..f4ce7ed 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html @@ -4,38 +4,34 @@

There was an error loading images.

} @else if (imageViewsQuery.isSuccess()) {
- @for (imageQuery of imageQueries(); track $index) { + @for (imageQuery of imageViewsWithBlobUrlsQueries(); track $index) { @if (imageQuery.isLoading()) { } @else if (imageQuery.isError()) {

There was an error loading this image.

} @else { - @let imageData = imageQuery.data(); + @let imageView = imageQuery.data()!; imageData!.imageView.alt -

{{ imageData!.imageView.filename }}

+

{{ imageView.filename }}

- Main? - - + +
diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts index 516a8af..9c7efe6 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts @@ -1,7 +1,7 @@ import { Component, computed, inject, input, output, signal } from '@angular/core'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { ImageService } from '../../../../../shared/services/ImageService'; -import { injectQuery, keepPreviousData, QueryClient } from '@tanstack/angular-query-experimental'; +import { injectQuery, keepPreviousData } from '@tanstack/angular-query-experimental'; import { Spinner } from '../../../../../shared/components/spinner/spinner'; import { ImageView } from '../../../../../shared/models/ImageView.model'; import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries-experimental'; @@ -42,7 +42,6 @@ export class ImageSelect { private readonly imageService = inject(ImageService); private readonly dialog = inject(MatDialog); - private readonly queryClient = inject(QueryClient); protected readonly imageViewsQuery = injectQuery(() => ({ queryKey: ['image-views', this.currentPage(), this.pageSize()], @@ -68,20 +67,12 @@ export class ImageSelect { } }); - protected readonly imageQueries = injectQueries(() => ({ + protected readonly imageViewsWithBlobUrlsQueries = injectQueries(() => ({ queries: - this.imageViewsQuery.data()?.content.map((imageView) => { - return { - queryKey: ['images', imageView.owner.username, imageView.filename], - queryFn: async () => { - const blobUrl = await this.imageService.getImage(imageView.url); - return { - blobUrl, - imageView, - }; - }, - }; - }) ?? [], + this.imageViewsQuery.data()?.content.map((imageView) => ({ + queryKey: ['image-views-with-blob-urls', imageView.owner.username, imageView.filename], + queryFn: () => this.imageService.getImageViewWithBlobUrl(imageView), + })) ?? [], })); protected onPage(pageEvent: PageEvent): void { @@ -120,12 +111,6 @@ export class ImageSelect { protected async deleteImage(imageView: ImageView): Promise { await this.imageService.deleteImage(imageView.owner.username, imageView.filename); - await this.queryClient.invalidateQueries({ - queryKey: ['image-views'], - }); - await this.queryClient.invalidateQueries({ - queryKey: ['images'], - }); } protected readonly faEllipsis = faEllipsis; diff --git a/src/app/shared/client-models/ImageViewWithBlobUrl.ts b/src/app/shared/client-models/ImageViewWithBlobUrl.ts new file mode 100644 index 0000000..f75b3a9 --- /dev/null +++ b/src/app/shared/client-models/ImageViewWithBlobUrl.ts @@ -0,0 +1,5 @@ +import { ImageView } from '../models/ImageView.model'; + +export interface ImageViewWithBlobUrl extends ImageView { + blobUrl: string; +} diff --git a/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.html b/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.html index ce4a359..75405e1 100644 --- a/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.html +++ b/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.html @@ -1,15 +1,16 @@ @let recipe = this.recipe();
- @if (mainImage.isSuccess()) { - @let maybeMainImageUrl = mainImage.data(); - @if (!!maybeMainImageUrl) { - - } @else { -
- -
- } + @if (mainImage.isLoading()) { + + } @else if (mainImage.isError()) { +

There was an error loading the image.

+ } @else if (mainImage.isEnabled()) { + + } @else { +
+ +
}
diff --git a/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.ts b/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.ts index 3bb13c4..f8e4086 100644 --- a/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.ts +++ b/src/app/shared/components/recipe-card-grid/recipe-card/recipe-card.ts @@ -1,15 +1,17 @@ import { Component, computed, inject, input } from '@angular/core'; import { Recipe } from '../../../models/Recipe.model'; import { RouterLink } from '@angular/router'; -import { injectQuery } from '@tanstack/angular-query-experimental'; +import { CreateQueryOptions, injectQuery } from '@tanstack/angular-query-experimental'; import { ImageService } from '../../../services/ImageService'; import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { Logo } from '../../logo/logo'; +import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl'; +import { Spinner } from '../../spinner/spinner'; @Component({ selector: 'app-recipe-card', - imports: [RouterLink, FaIconComponent, Logo], + imports: [RouterLink, FaIconComponent, Logo, Spinner], templateUrl: './recipe-card.html', styleUrl: './recipe-card.css', }) @@ -29,10 +31,20 @@ export class RecipeCard { private readonly imageService = inject(ImageService); protected readonly mainImage = injectQuery(() => { - const recipe = this.recipe(); - return { - queryKey: ['recipe-main-images', recipe.owner.username, recipe.slug], - queryFn: () => this.imageService.getImage(recipe.mainImage?.url), - }; + const mainImageView = this.recipe().mainImage; + let options: Partial< + CreateQueryOptions + > = {}; + if (mainImageView) { + options = this.imageService.getImage(mainImageView); + } else { + options.enabled = false; + } + return options as CreateQueryOptions< + ImageViewWithBlobUrl, + Error, + ImageViewWithBlobUrl, + [string, string, string] + >; }); } diff --git a/src/app/shared/services/ImageService.ts b/src/app/shared/services/ImageService.ts index abe6097..7122981 100644 --- a/src/app/shared/services/ImageService.ts +++ b/src/app/shared/services/ImageService.ts @@ -1,10 +1,12 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { firstValueFrom, map } from 'rxjs'; +import { firstValueFrom, map, tap } from 'rxjs'; import { EndpointService } from './EndpointService'; import { ImageView } from '../models/ImageView.model'; import { SliceView } from '../models/SliceView.model'; import { QueryParams } from '../models/Query.model'; +import { QueryClient, QueryOptions, queryOptions } from '@tanstack/angular-query-experimental'; +import { ImageViewWithBlobUrl } from '../client-models/ImageViewWithBlobUrl'; @Injectable({ providedIn: 'root', @@ -27,6 +29,7 @@ export class ImageService { private readonly httpClient = inject(HttpClient); private readonly endpointService = inject(EndpointService); + private readonly queryClient = inject(QueryClient); public getOwnedImages(queryParams?: QueryParams): Promise> { return firstValueFrom( @@ -34,21 +37,32 @@ export class ImageService { ); } - /** - * TODO: this api should not accept null as an input - */ - public getImage(backendUrl?: string | null): Promise { - if (!!backendUrl) { - return firstValueFrom( - this.httpClient - .get(backendUrl, { - responseType: 'blob', - }) - .pipe(map((blob) => URL.createObjectURL(blob))), - ); - } else { - return Promise.resolve(null); - } + public getImageViewWithBlobUrl(imageView: ImageView): Promise { + return firstValueFrom( + this.httpClient + .get(this.endpointService.getUrl('images', [imageView.owner.username, imageView.filename]), { + responseType: 'blob', + }) + .pipe( + map((blob) => URL.createObjectURL(blob)), + map( + (blobUrl) => + ({ + ...imageView, + blobUrl, + }) satisfies ImageViewWithBlobUrl, + ), + ), + ); + } + + public getImage( + imageView: ImageView, + ): QueryOptions { + return queryOptions({ + queryKey: ['image-views-with-blob-urls', imageView.owner.username, imageView.filename], + queryFn: () => this.getImageViewWithBlobUrl(imageView), + }); } public uploadImage( @@ -71,7 +85,15 @@ export class ImageService { formData.append('isPublic', isPublic.toString()); } - return firstValueFrom(this.httpClient.post(this.endpointService.getUrl('images'), formData)); + return firstValueFrom( + this.httpClient.post(this.endpointService.getUrl('images'), formData).pipe( + tap(async () => { + await this.queryClient.invalidateQueries({ + queryKey: ['image-views'], + }); + }), + ), + ); } public imageExists(username: string, filename: string): Promise { @@ -84,7 +106,16 @@ export class ImageService { public deleteImage(username: string, filename: string): Promise { return firstValueFrom( - this.httpClient.delete(this.endpointService.getUrl('images', [username, filename])), + this.httpClient.delete(this.endpointService.getUrl('images', [username, filename])).pipe( + tap(async () => { + await this.queryClient.refetchQueries({ + queryKey: ['image-views', username, filename], + }); + await this.queryClient.refetchQueries({ + queryKey: ['image-views-with-blob-urls', username, filename], + }); + }), + ), ); } @@ -101,7 +132,16 @@ export class ImageService { }, ): Promise { return firstValueFrom( - this.httpClient.put(this.endpointService.getUrl('images', [username, filename]), data), + this.httpClient.put(this.endpointService.getUrl('images', [username, filename]), data).pipe( + tap(async () => { + await this.queryClient.refetchQueries({ + queryKey: ['image-views', username, filename], + }); + await this.queryClient.refetchQueries({ + queryKey: ['image-views-with-blob-urls', username, filename], + }); + }), + ), ); } }