MME-8 Less query, better handling of observables.
This commit is contained in:
parent
352110a372
commit
8c0a4dd9f4
@ -1,43 +1,41 @@
|
|||||||
@if (imageViewsQuery.isLoading()) {
|
@if (loadingImages()) {
|
||||||
<app-spinner></app-spinner>
|
<app-spinner></app-spinner>
|
||||||
} @else if (imageViewsQuery.isError()) {
|
} @else if (loadImagesError()) {
|
||||||
<p>There was an error loading images.</p>
|
<p>There was an error loading images.</p>
|
||||||
} @else if (imageViewsQuery.isSuccess()) {
|
} @else {
|
||||||
<div class="image-grid">
|
<div class="image-grid">
|
||||||
@for (imageQuery of imageViewsWithBlobUrlsQueries(); track $index) {
|
@for (image of imagesSlice()!.content; 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()!;
|
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<img
|
<img
|
||||||
mat-card-image
|
mat-card-image
|
||||||
[src]="imageView.blobUrl"
|
[src]="image.blobUrl"
|
||||||
alt="imageData!.imageView.alt"
|
[alt]="image.alt"
|
||||||
(click)="onImageClick(imageView)"
|
(click)="onImageClick(image)"
|
||||||
class="image-grid-image"
|
class="image-grid-image"
|
||||||
/>
|
/>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<p class="image-filename">{{ imageView.filename }}</p>
|
<p class="image-filename">{{ image.filename }}</p>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
<mat-checkbox [checked]="isSelected(imageView)" (click)="onImageClick(imageView)"
|
<mat-checkbox [checked]="isSelected(image)" (click)="onImageClick(image)">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(imageView)">Edit</button>
|
<button mat-menu-item type="button" (click)="editImage(image)">Edit</button>
|
||||||
<button mat-menu-item type="button" (click)="deleteImage(imageView)">Delete</button>
|
<button mat-menu-item type="button" (click)="deleteImage(image)">Delete</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (deleting()) {
|
||||||
|
<app-spinner size="24px"></app-spinner>
|
||||||
|
} @else if (deleteError()) {
|
||||||
|
<p>There was an error deleting the image.</p>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-paginator
|
<mat-paginator
|
||||||
[length]="imageCount()"
|
[length]="imageCount()"
|
||||||
[pageSize]="pageSize()"
|
[pageSize]="pageSize()"
|
||||||
|
|||||||
@ -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 { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||||
import { ImageService } from '../../../../../shared/services/ImageService';
|
import { ImageService } from '../../../../../shared/services/ImageService';
|
||||||
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 { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
|
import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
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 { MatButton } from '@angular/material/button';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { EditImageDialog } from './edit-image-dialog/edit-image-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({
|
@Component({
|
||||||
selector: 'app-image-select',
|
selector: 'app-image-select',
|
||||||
@ -33,7 +33,7 @@ import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
|
|||||||
templateUrl: './image-select.html',
|
templateUrl: './image-select.html',
|
||||||
styleUrl: './image-select.css',
|
styleUrl: './image-select.css',
|
||||||
})
|
})
|
||||||
export class ImageSelect {
|
export class ImageSelect implements OnInit {
|
||||||
public readonly select = output<ImageView | null>();
|
public readonly select = output<ImageView | null>();
|
||||||
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
|
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
|
||||||
|
|
||||||
@ -43,37 +43,49 @@ export class ImageSelect {
|
|||||||
private readonly imageService = inject(ImageService);
|
private readonly imageService = inject(ImageService);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
protected readonly imageViewsQuery = injectQuery(() => ({
|
protected readonly loadingImages = signal(false);
|
||||||
queryKey: ['image-views', this.currentPage(), this.pageSize()],
|
protected readonly loadImagesError = signal<Error | null>(null);
|
||||||
queryFn: () =>
|
protected readonly imagesSlice = signal<SliceView<ImageViewWithBlobUrl> | null>(null);
|
||||||
this.imageService.getOwnedImages({
|
|
||||||
|
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(),
|
page: this.currentPage(),
|
||||||
|
size: this.pageSize(),
|
||||||
sort: [
|
sort: [
|
||||||
{
|
{
|
||||||
property: 'created',
|
property: 'created',
|
||||||
order: 'DESC',
|
order: 'DESC',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
size: this.pageSize(),
|
})
|
||||||
}),
|
.subscribe({
|
||||||
placeholderData: keepPreviousData,
|
next: (sliceView) => {
|
||||||
}));
|
sliceView.content.sort((a, b) => b.created.valueOf() - a.created.valueOf());
|
||||||
|
this.loadingImages.set(false);
|
||||||
protected readonly imageCount = computed(() => {
|
this.imagesSlice.set(sliceView);
|
||||||
if (this.imageViewsQuery.isSuccess()) {
|
},
|
||||||
return this.imageViewsQuery.data()!.count;
|
error: (e) => {
|
||||||
} else {
|
this.loadingImages.set(false);
|
||||||
return 0;
|
this.loadImagesError.set(e);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
this.imageService.getOwnedImagesCount().subscribe({
|
||||||
protected readonly imageViewsWithBlobUrlsQueries = injectQueries(() => ({
|
next: (count) => {
|
||||||
queries:
|
this.imageCount.set(count);
|
||||||
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 {
|
protected onPage(pageEvent: PageEvent): void {
|
||||||
if (pageEvent.pageIndex < this.currentPage()) {
|
if (pageEvent.pageIndex < this.currentPage()) {
|
||||||
@ -81,9 +93,10 @@ export class ImageSelect {
|
|||||||
this.currentPage.update((old) => Math.max(old - 1, 0));
|
this.currentPage.update((old) => Math.max(old - 1, 0));
|
||||||
} else {
|
} else {
|
||||||
// forward
|
// 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.pageSize.set(pageEvent.pageSize);
|
||||||
|
this.loadImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onImageClick(imageView: ImageView): void {
|
protected onImageClick(imageView: ImageView): void {
|
||||||
@ -109,8 +122,18 @@ export class ImageSelect {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async deleteImage(imageView: ImageView): Promise<void> {
|
protected deleteImage(imageView: ImageView): void {
|
||||||
await this.imageService.deleteImage(imageView.owner.username, imageView.filename);
|
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;
|
protected readonly faEllipsis = faEllipsis;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export interface QueryParams<T extends readonly string[] = any> {
|
|||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
sort?: Array<string | Sort<T>>;
|
sort?: Array<string | Sort<T>>;
|
||||||
|
custom?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Sort<T extends readonly string[] = any> {
|
export interface Sort<T extends readonly string[] = any> {
|
||||||
|
|||||||
@ -33,6 +33,11 @@ export class EndpointService {
|
|||||||
urlSearchParams.append('sort', sortString);
|
urlSearchParams.append('sort', sortString);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (queryParams?.custom !== undefined) {
|
||||||
|
Object.entries(queryParams.custom).forEach(([key, value]) => {
|
||||||
|
urlSearchParams.append(key, value.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let pathString = pathParts?.join('/') || '';
|
let pathString = pathParts?.join('/') || '';
|
||||||
if (pathString?.length) {
|
if (pathString?.length) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
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, Observable, tap } from 'rxjs';
|
import { firstValueFrom, from, map, mergeMap, Observable, tap, toArray } 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';
|
||||||
@ -41,10 +41,53 @@ export class ImageService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
|
public getOwnedImageViewsWithBlobUrls(
|
||||||
return firstValueFrom(
|
queryParams?: QueryParams<typeof ImageService.ImageProps>,
|
||||||
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams)),
|
): 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> {
|
public getImageViewWithBlobUrl(imageView: ImageView): Promise<ImageViewWithBlobUrl> {
|
||||||
@ -120,19 +163,8 @@ export class ImageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteImage(username: string, filename: string): Promise<void> {
|
public deleteImage(username: string, filename: string): Observable<void> {
|
||||||
return firstValueFrom(
|
return 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],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateImage(username: string, filename: string, data: ImageUpdateBody): Observable<ImageView> {
|
public updateImage(username: string, filename: string, data: ImageUpdateBody): Observable<ImageView> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user