MME-8 WIP refactoring queries for image views, small style. TODO: mutations.

This commit is contained in:
Jesse Brault 2026-02-06 20:24:48 -06:00
parent 76ed65a144
commit 7c483ba5f6
10 changed files with 142 additions and 94 deletions

View File

@ -30,17 +30,18 @@
} }
</div> </div>
</div> </div>
@if (mainImageUrl.isSuccess()) { @if (mainImageQuery.isLoading()) {
@let maybeMainImageUrl = mainImageUrl.data(); <app-spinner></app-spinner>
@if (!!maybeMainImageUrl) { } @else if (mainImageQuery.isError()) {
<img <p>There was an error loading the main image.</p>
id="main-image" } @else if (mainImageQuery.isEnabled()) {
[src]="maybeMainImageUrl" <img
[alt]="recipe.mainImage!.alt" id="main-image"
[height]="recipe.mainImage!.height" [src]="mainImageQuery.data()!.blobUrl"
[width]="recipe.mainImage!.width" [alt]="recipe.mainImage!.alt"
/> [height]="recipe.mainImage!.height"
} [width]="recipe.mainImage!.width"
/>
} }
<div [innerHTML]="recipe.text"></div> <div [innerHTML]="recipe.text"></div>
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" /> <app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />

View File

@ -1,6 +1,6 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { RecipeView } from '../../../shared/models/Recipe.model'; 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 { ImageService } from '../../../shared/services/ImageService';
import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@ -8,10 +8,12 @@ import { RecipeService } from '../../../shared/services/RecipeService';
import { AuthService } from '../../../shared/services/AuthService'; import { AuthService } from '../../../shared/services/AuthService';
import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list'; import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { Spinner } from '../../../shared/components/spinner/spinner';
import { ImageViewWithBlobUrl } from '../../../shared/client-models/ImageViewWithBlobUrl';
@Component({ @Component({
selector: 'app-recipe-page-content', selector: 'app-recipe-page-content',
imports: [FaIconComponent, RecipeCommentsList, MatButton], imports: [FaIconComponent, RecipeCommentsList, MatButton, Spinner],
templateUrl: './recipe-page-content.html', templateUrl: './recipe-page-content.html',
styleUrl: './recipe-page-content.css', styleUrl: './recipe-page-content.css',
}) })
@ -24,12 +26,22 @@ export class RecipePageContent {
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected readonly mainImageUrl = injectQuery(() => { protected readonly mainImageQuery = injectQuery(() => {
const recipe = this.recipeView().recipe; let options: Partial<
return { CreateQueryOptions<ImageViewWithBlobUrl, Error, ImageViewWithBlobUrl, [string, string, string]>
queryKey: ['recipe-main-images', recipe.owner.username, recipe.slug], > = {};
queryFn: () => this.imageService.getImage(recipe.mainImage?.url), 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(() => ({ protected readonly starMutation = injectMutation(() => ({

View File

@ -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 { DialogContainer } from '../../../../../../shared/components/dialog-container/dialog-container';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { ImageService } from '../../../../../../shared/services/ImageService'; import { ImageService } from '../../../../../../shared/services/ImageService';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Component({ @Component({
selector: 'app-edit-image-dialog', selector: 'app-edit-image-dialog',
@ -17,7 +16,6 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
export class EditImageDialog implements OnInit { export class EditImageDialog implements OnInit {
protected readonly imageView: ImageView = inject(MAT_DIALOG_DATA); protected readonly imageView: ImageView = inject(MAT_DIALOG_DATA);
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
private readonly queryClient = inject(QueryClient);
private readonly dialogRef = inject(MatDialogRef); private readonly dialogRef = inject(MatDialogRef);
protected readonly imageForm = new FormGroup({ protected readonly imageForm = new FormGroup({
@ -46,9 +44,6 @@ export class EditImageDialog implements OnInit {
alt: this.imageForm.value.alt, alt: this.imageForm.value.alt,
caption: this.imageForm.value.caption, caption: this.imageForm.value.caption,
}); });
await this.queryClient.invalidateQueries({
queryKey: ['image-views'],
});
this.dialogRef.close(); this.dialogRef.close();
} }
} }

View File

@ -10,7 +10,8 @@
object-fit: cover; object-fit: cover;
} }
mat-card-content p { .image-filename {
font-size: 0.75em;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }

View File

@ -4,38 +4,34 @@
<p>There was an error loading images.</p> <p>There was an error loading images.</p>
} @else if (imageViewsQuery.isSuccess()) { } @else if (imageViewsQuery.isSuccess()) {
<div class="image-grid"> <div class="image-grid">
@for (imageQuery of imageQueries(); track $index) { @for (imageQuery of imageViewsWithBlobUrlsQueries(); track $index) {
@if (imageQuery.isLoading()) { @if (imageQuery.isLoading()) {
<app-spinner></app-spinner> <app-spinner></app-spinner>
} @else if (imageQuery.isError()) { } @else if (imageQuery.isError()) {
<p>There was an error loading this image.</p> <p>There was an error loading this image.</p>
} @else { } @else {
@let imageData = imageQuery.data(); @let imageView = imageQuery.data()!;
<mat-card> <mat-card>
<img <img
mat-card-image mat-card-image
[src]="imageData!.blobUrl" [src]="imageView.blobUrl"
alt="imageData!.imageView.alt" alt="imageData!.imageView.alt"
(click)="onImageClick(imageData!.imageView)" (click)="onImageClick(imageView)"
class="image-grid-image" class="image-grid-image"
/> />
<mat-card-content> <mat-card-content>
<p>{{ imageData!.imageView.filename }}</p> <p class="image-filename">{{ imageView.filename }}</p>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<mat-checkbox <mat-checkbox [checked]="isSelected(imageView)" (click)="onImageClick(imageView)"
[checked]="isSelected(imageData!.imageView)"
(click)="onImageClick(imageData!.imageView)"
>Main?</mat-checkbox >Main?</mat-checkbox
> >
<button matButton="text" type="button" [matMenuTriggerFor]="imageActionsMenu"> <button matButton="text" type="button" [matMenuTriggerFor]="imageActionsMenu">
<fa-icon [icon]="faEllipsis"></fa-icon> <fa-icon [icon]="faEllipsis"></fa-icon>
</button> </button>
<mat-menu #imageActionsMenu> <mat-menu #imageActionsMenu>
<button mat-menu-item type="button" (click)="editImage(imageData!.imageView)">Edit</button> <button mat-menu-item type="button" (click)="editImage(imageView)">Edit</button>
<button mat-menu-item type="button" (click)="deleteImage(imageData!.imageView)"> <button mat-menu-item type="button" (click)="deleteImage(imageView)">Delete</button>
Delete
</button>
</mat-menu> </mat-menu>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>

View File

@ -1,7 +1,7 @@
import { Component, computed, inject, input, output, signal } from '@angular/core'; import { Component, computed, inject, input, output, signal } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { ImageService } from '../../../../../shared/services/ImageService'; 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 { Spinner } from '../../../../../shared/components/spinner/spinner';
import { ImageView } from '../../../../../shared/models/ImageView.model'; import { ImageView } from '../../../../../shared/models/ImageView.model';
import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries-experimental'; import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries-experimental';
@ -42,7 +42,6 @@ export class ImageSelect {
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly queryClient = inject(QueryClient);
protected readonly imageViewsQuery = injectQuery(() => ({ protected readonly imageViewsQuery = injectQuery(() => ({
queryKey: ['image-views', this.currentPage(), this.pageSize()], queryKey: ['image-views', this.currentPage(), this.pageSize()],
@ -68,20 +67,12 @@ export class ImageSelect {
} }
}); });
protected readonly imageQueries = injectQueries(() => ({ protected readonly imageViewsWithBlobUrlsQueries = injectQueries(() => ({
queries: queries:
this.imageViewsQuery.data()?.content.map((imageView) => { this.imageViewsQuery.data()?.content.map((imageView) => ({
return { queryKey: ['image-views-with-blob-urls', imageView.owner.username, imageView.filename],
queryKey: ['images', imageView.owner.username, imageView.filename], queryFn: () => this.imageService.getImageViewWithBlobUrl(imageView),
queryFn: async () => { })) ?? [],
const blobUrl = await this.imageService.getImage(imageView.url);
return {
blobUrl,
imageView,
};
},
};
}) ?? [],
})); }));
protected onPage(pageEvent: PageEvent): void { protected onPage(pageEvent: PageEvent): void {
@ -120,12 +111,6 @@ export class ImageSelect {
protected async deleteImage(imageView: ImageView): Promise<void> { protected async deleteImage(imageView: ImageView): Promise<void> {
await this.imageService.deleteImage(imageView.owner.username, imageView.filename); 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; protected readonly faEllipsis = faEllipsis;

View File

@ -0,0 +1,5 @@
import { ImageView } from '../models/ImageView.model';
export interface ImageViewWithBlobUrl extends ImageView {
blobUrl: string;
}

View File

@ -1,15 +1,16 @@
@let recipe = this.recipe(); @let recipe = this.recipe();
<article> <article>
<a [routerLink]="recipePageLink()"> <a [routerLink]="recipePageLink()">
@if (mainImage.isSuccess()) { @if (mainImage.isLoading()) {
@let maybeMainImageUrl = mainImage.data(); <app-spinner></app-spinner>
@if (!!maybeMainImageUrl) { } @else if (mainImage.isError()) {
<img [src]="maybeMainImageUrl" id="recipe-card-image" [alt]="recipe.mainImage!.alt" /> <p>There was an error loading the image.</p>
} @else { } @else if (mainImage.isEnabled()) {
<div class="recipe-card-image-placeholder"> <img [src]="mainImage.data()!.blobUrl" id="recipe-card-image" [alt]="recipe.mainImage!.alt" />
<app-logo></app-logo> } @else {
</div> <div class="recipe-card-image-placeholder">
} <app-logo></app-logo>
</div>
} }
</a> </a>
<div id="title-and-visibility"> <div id="title-and-visibility">

View File

@ -1,15 +1,17 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { Recipe } from '../../../models/Recipe.model'; import { Recipe } from '../../../models/Recipe.model';
import { RouterLink } from '@angular/router'; 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 { ImageService } from '../../../services/ImageService';
import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Logo } from '../../logo/logo'; import { Logo } from '../../logo/logo';
import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
import { Spinner } from '../../spinner/spinner';
@Component({ @Component({
selector: 'app-recipe-card', selector: 'app-recipe-card',
imports: [RouterLink, FaIconComponent, Logo], imports: [RouterLink, FaIconComponent, Logo, Spinner],
templateUrl: './recipe-card.html', templateUrl: './recipe-card.html',
styleUrl: './recipe-card.css', styleUrl: './recipe-card.css',
}) })
@ -29,10 +31,20 @@ export class RecipeCard {
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
protected readonly mainImage = injectQuery(() => { protected readonly mainImage = injectQuery(() => {
const recipe = this.recipe(); const mainImageView = this.recipe().mainImage;
return { let options: Partial<
queryKey: ['recipe-main-images', recipe.owner.username, recipe.slug], CreateQueryOptions<ImageViewWithBlobUrl, Error, ImageViewWithBlobUrl, [string, string, string]>
queryFn: () => this.imageService.getImage(recipe.mainImage?.url), > = {};
}; if (mainImageView) {
options = this.imageService.getImage(mainImageView);
} else {
options.enabled = false;
}
return options as CreateQueryOptions<
ImageViewWithBlobUrl,
Error,
ImageViewWithBlobUrl,
[string, string, string]
>;
}); });
} }

View File

@ -1,10 +1,12 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom, map } from 'rxjs'; import { firstValueFrom, map, tap } from 'rxjs';
import { EndpointService } from './EndpointService'; import { EndpointService } from './EndpointService';
import { ImageView } from '../models/ImageView.model'; import { ImageView } from '../models/ImageView.model';
import { SliceView } from '../models/SliceView.model'; import { SliceView } from '../models/SliceView.model';
import { QueryParams } from '../models/Query.model'; import { QueryParams } from '../models/Query.model';
import { QueryClient, QueryOptions, queryOptions } from '@tanstack/angular-query-experimental';
import { ImageViewWithBlobUrl } from '../client-models/ImageViewWithBlobUrl';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -27,6 +29,7 @@ export class ImageService {
private readonly httpClient = inject(HttpClient); private readonly httpClient = inject(HttpClient);
private readonly endpointService = inject(EndpointService); private readonly endpointService = inject(EndpointService);
private readonly queryClient = inject(QueryClient);
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> { public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
return firstValueFrom( return firstValueFrom(
@ -34,21 +37,32 @@ export class ImageService {
); );
} }
/** public getImageViewWithBlobUrl(imageView: ImageView): Promise<ImageViewWithBlobUrl> {
* TODO: this api should not accept null as an input return firstValueFrom(
*/ this.httpClient
public getImage(backendUrl?: string | null): Promise<string | null> { .get(this.endpointService.getUrl('images', [imageView.owner.username, imageView.filename]), {
if (!!backendUrl) { responseType: 'blob',
return firstValueFrom( })
this.httpClient .pipe(
.get(backendUrl, { map((blob) => URL.createObjectURL(blob)),
responseType: 'blob', map(
}) (blobUrl) =>
.pipe(map((blob) => URL.createObjectURL(blob))), ({
); ...imageView,
} else { blobUrl,
return Promise.resolve(null); }) satisfies ImageViewWithBlobUrl,
} ),
),
);
}
public getImage(
imageView: ImageView,
): QueryOptions<ImageViewWithBlobUrl, Error, ImageViewWithBlobUrl, [string, string, string]> {
return queryOptions({
queryKey: ['image-views-with-blob-urls', imageView.owner.username, imageView.filename],
queryFn: () => this.getImageViewWithBlobUrl(imageView),
});
} }
public uploadImage( public uploadImage(
@ -71,7 +85,15 @@ export class ImageService {
formData.append('isPublic', isPublic.toString()); formData.append('isPublic', isPublic.toString());
} }
return firstValueFrom(this.httpClient.post<ImageView>(this.endpointService.getUrl('images'), formData)); return firstValueFrom(
this.httpClient.post<ImageView>(this.endpointService.getUrl('images'), formData).pipe(
tap(async () => {
await this.queryClient.invalidateQueries({
queryKey: ['image-views'],
});
}),
),
);
} }
public imageExists(username: string, filename: string): Promise<boolean> { public imageExists(username: string, filename: string): Promise<boolean> {
@ -84,7 +106,16 @@ export class ImageService {
public deleteImage(username: string, filename: string): Promise<void> { public deleteImage(username: string, filename: string): Promise<void> {
return firstValueFrom( return firstValueFrom(
this.httpClient.delete<void>(this.endpointService.getUrl('images', [username, filename])), this.httpClient.delete<void>(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<ImageView> { ): Promise<ImageView> {
return firstValueFrom( return firstValueFrom(
this.httpClient.put<ImageView>(this.endpointService.getUrl('images', [username, filename]), data), this.httpClient.put<ImageView>(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],
});
}),
),
); );
} }
} }