MME-8 Less query, better handling of observables.

This commit is contained in:
Jesse Brault 2026-02-07 21:22:18 -06:00
parent 352110a372
commit 8c0a4dd9f4
5 changed files with 143 additions and 84 deletions

View File

@ -1,43 +1,41 @@
@if (imageViewsQuery.isLoading()) {
@if (loadingImages()) {
<app-spinner></app-spinner>
} @else if (imageViewsQuery.isError()) {
} @else if (loadImagesError()) {
<p>There was an error loading images.</p>
} @else if (imageViewsQuery.isSuccess()) {
<div class="image-grid">
@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 imageView = imageQuery.data()!;
<div class="image-grid">
@for (image of imagesSlice()!.content; track $index) {
<mat-card>
<img
mat-card-image
[src]="imageView.blobUrl"
alt="imageData!.imageView.alt"
(click)="onImageClick(imageView)"
[src]="image.blobUrl"
[alt]="image.alt"
(click)="onImageClick(image)"
class="image-grid-image"
/>
<mat-card-content>
<p class="image-filename">{{ imageView.filename }}</p>
<p class="image-filename">{{ image.filename }}</p>
</mat-card-content>
<mat-card-actions>
<mat-checkbox [checked]="isSelected(imageView)" (click)="onImageClick(imageView)"
>Main?</mat-checkbox
>
<mat-checkbox [checked]="isSelected(image)" (click)="onImageClick(image)">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(imageView)">Edit</button>
<button mat-menu-item type="button" (click)="deleteImage(imageView)">Delete</button>
<button mat-menu-item type="button" (click)="editImage(image)">Edit</button>
<button mat-menu-item type="button" (click)="deleteImage(image)">Delete</button>
</mat-menu>
</mat-card-actions>
</mat-card>
}
}
</div>
@if (deleting()) {
<app-spinner size="24px"></app-spinner>
} @else if (deleteError()) {
<p>There was an error deleting the image.</p>
}
<mat-paginator
[length]="imageCount()"
[pageSize]="pageSize()"

View File

@ -1,10 +1,8 @@
import { Component, computed, inject, input, output, signal } from '@angular/core';
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { ImageService } from '../../../../../shared/services/ImageService';
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';
import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
@ -13,6 +11,8 @@ import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
import { SliceView } from '../../../../../shared/models/SliceView.model';
import { ImageViewWithBlobUrl } from '../../../../../shared/client-models/ImageViewWithBlobUrl';
@Component({
selector: 'app-image-select',
@ -33,7 +33,7 @@ import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
templateUrl: './image-select.html',
styleUrl: './image-select.css',
})
export class ImageSelect {
export class ImageSelect implements OnInit {
public readonly select = output<ImageView | null>();
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
@ -43,37 +43,49 @@ export class ImageSelect {
private readonly imageService = inject(ImageService);
private readonly dialog = inject(MatDialog);
protected readonly imageViewsQuery = injectQuery(() => ({
queryKey: ['image-views', this.currentPage(), this.pageSize()],
queryFn: () =>
this.imageService.getOwnedImages({
protected readonly loadingImages = signal(false);
protected readonly loadImagesError = signal<Error | null>(null);
protected readonly imagesSlice = signal<SliceView<ImageViewWithBlobUrl> | null>(null);
protected readonly imageCount = signal(0);
protected readonly deleting = signal(false);
protected readonly deleteError = signal<Error | null>(null);
public ngOnInit(): void {
this.loadImages();
}
private loadImages(): void {
this.loadingImages.set(true);
this.imageService
.getOwnedImageViewsWithBlobUrls({
page: this.currentPage(),
size: this.pageSize(),
sort: [
{
property: 'created',
order: 'DESC',
},
],
size: this.pageSize(),
}),
placeholderData: keepPreviousData,
}));
protected readonly imageCount = computed(() => {
if (this.imageViewsQuery.isSuccess()) {
return this.imageViewsQuery.data()!.count;
} else {
return 0;
}
})
.subscribe({
next: (sliceView) => {
sliceView.content.sort((a, b) => b.created.valueOf() - a.created.valueOf());
this.loadingImages.set(false);
this.imagesSlice.set(sliceView);
},
error: (e) => {
this.loadingImages.set(false);
this.loadImagesError.set(e);
},
});
protected readonly imageViewsWithBlobUrlsQueries = injectQueries(() => ({
queries:
this.imageViewsQuery.data()?.content.map((imageView) => ({
queryKey: ['image-views-with-blob-urls', imageView.owner.username, imageView.filename],
queryFn: () => this.imageService.getImageViewWithBlobUrl(imageView),
})) ?? [],
}));
this.imageService.getOwnedImagesCount().subscribe({
next: (count) => {
this.imageCount.set(count);
},
});
}
protected onPage(pageEvent: PageEvent): void {
if (pageEvent.pageIndex < this.currentPage()) {
@ -81,9 +93,10 @@ export class ImageSelect {
this.currentPage.update((old) => Math.max(old - 1, 0));
} else {
// forward
this.currentPage.update((old) => (this.imageViewsQuery.data()?.slice.hasNext ? old + 1 : old));
this.currentPage.update((old) => (this.imagesSlice()?.slice.hasNext ? old + 1 : old));
}
this.pageSize.set(pageEvent.pageSize);
this.loadImages();
}
protected onImageClick(imageView: ImageView): void {
@ -109,8 +122,18 @@ export class ImageSelect {
});
}
protected async deleteImage(imageView: ImageView): Promise<void> {
await this.imageService.deleteImage(imageView.owner.username, imageView.filename);
protected deleteImage(imageView: ImageView): void {
this.deleting.set(true);
this.imageService.deleteImage(imageView.owner.username, imageView.filename).subscribe({
next: () => {
this.deleting.set(false);
this.loadImages();
},
error: (e) => {
this.deleting.set(false);
this.deleteError.set(e);
},
});
}
protected readonly faEllipsis = faEllipsis;

View File

@ -2,6 +2,7 @@ export interface QueryParams<T extends readonly string[] = any> {
page?: number;
size?: number;
sort?: Array<string | Sort<T>>;
custom?: Record<string, any>;
}
export interface Sort<T extends readonly string[] = any> {

View File

@ -33,6 +33,11 @@ export class EndpointService {
urlSearchParams.append('sort', sortString);
}
});
if (queryParams?.custom !== undefined) {
Object.entries(queryParams.custom).forEach(([key, value]) => {
urlSearchParams.append(key, value.toString());
});
}
let pathString = pathParts?.join('/') || '';
if (pathString?.length) {

View File

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom, map, Observable, tap } from 'rxjs';
import { firstValueFrom, from, map, mergeMap, Observable, tap, toArray } from 'rxjs';
import { EndpointService } from './EndpointService';
import { ImageView } from '../models/ImageView.model';
import { SliceView } from '../models/SliceView.model';
@ -41,10 +41,53 @@ export class ImageService {
};
}
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
return firstValueFrom(
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams)),
public getOwnedImageViewsWithBlobUrls(
queryParams?: QueryParams<typeof ImageService.ImageProps>,
): Observable<SliceView<ImageViewWithBlobUrl>> {
return this.httpClient
.get<SliceView<WithStringDates<ImageView>>>(this.endpointService.getUrl('images', [], queryParams))
.pipe(
map((sliceView) => ({
...sliceView,
content: sliceView.content.map((withStringDates) => this.hydrateImageView(withStringDates)),
})),
mergeMap((sliceView) => {
return from(sliceView.content).pipe(
mergeMap((imageView) => {
return this.httpClient
.get(
this.endpointService.getUrl('images', [
imageView.owner.username,
imageView.filename,
]),
{
responseType: 'blob',
},
)
.pipe(
map((blob) => URL.createObjectURL(blob)),
map((blobUrl) => ({
...imageView,
blobUrl,
})),
);
}),
toArray(),
map((content) => {
return {
...sliceView,
content,
} satisfies SliceView<ImageViewWithBlobUrl>;
}),
);
}),
);
}
public getOwnedImagesCount(): Observable<number> {
return this.httpClient
.get<{ count: number }>(this.endpointService.getUrl('images', [], { custom: { count: true } }))
.pipe(map((res) => res.count));
}
public getImageViewWithBlobUrl(imageView: ImageView): Promise<ImageViewWithBlobUrl> {
@ -120,19 +163,8 @@ export class ImageService {
);
}
public deleteImage(username: string, filename: string): Promise<void> {
return firstValueFrom(
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],
});
}),
),
);
public deleteImage(username: string, filename: string): Observable<void> {
return this.httpClient.delete<void>(this.endpointService.getUrl('images', [username, filename]));
}
public updateImage(username: string, filename: string, data: ImageUpdateBody): Observable<ImageView> {