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

View File

@ -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<ImageViewWithBlobUrl, Error, ImageViewWithBlobUrl, [string, string, string]>
> = {};
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(() => ({

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 { 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();
}
}

View File

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

View File

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

View File

@ -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<void> {
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;

View File

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

View File

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

View File

@ -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<ImageViewWithBlobUrl, Error, ImageViewWithBlobUrl, [string, string, string]>
> = {};
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 { 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<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
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<string | null> {
if (!!backendUrl) {
public getImageViewWithBlobUrl(imageView: ImageView): Promise<ImageViewWithBlobUrl> {
return firstValueFrom(
this.httpClient
.get(backendUrl, {
.get(this.endpointService.getUrl('images', [imageView.owner.username, imageView.filename]), {
responseType: 'blob',
})
.pipe(map((blob) => URL.createObjectURL(blob))),
.pipe(
map((blob) => URL.createObjectURL(blob)),
map(
(blobUrl) =>
({
...imageView,
blobUrl,
}) satisfies ImageViewWithBlobUrl,
),
),
);
} else {
return Promise.resolve(null);
}
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(
@ -71,7 +85,15 @@ export class ImageService {
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> {
@ -84,7 +106,16 @@ export class ImageService {
public deleteImage(username: string, filename: string): Promise<void> {
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> {
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],
});
}),
),
);
}
}