Compare commits

..

No commits in common. "64b3cf92f72d5cf4115a9cbeb21c51f9c0dfeb87" and "352110a3725f67c17aa88817897c159925bad67a" have entirely different histories.

6 changed files with 85 additions and 166 deletions

View File

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

View File

@ -1,36 +1,14 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImageSelect } from './image-select'; import { ImageSelect } from './image-select';
import { Mocked } from 'vitest';
import { ImageService } from '../../../../../shared/services/ImageService';
import { of } from 'rxjs';
import { SliceView, SliceViewMeta } from '../../../../../shared/models/SliceView.model';
import { ImageViewWithBlobUrl } from '../../../../../shared/client-models/ImageViewWithBlobUrl';
describe('ImageSelect', () => { describe('ImageSelect', () => {
let component: ImageSelect; let component: ImageSelect;
let fixture: ComponentFixture<ImageSelect>; let fixture: ComponentFixture<ImageSelect>;
let imageService: Partial<Mocked<ImageService>> = {
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
of({
content: [],
slice: {} as SliceViewMeta,
count: 0,
} as SliceView<ImageViewWithBlobUrl>),
),
getOwnedImagesCount: vi.fn(() => of(0)),
deleteImage: vi.fn(() => of()),
};
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ImageSelect], imports: [ImageSelect],
providers: [
{
provide: ImageService,
useValue: imageService,
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(ImageSelect); fixture = TestBed.createComponent(ImageSelect);

View File

@ -1,8 +1,10 @@
import { Component, inject, input, OnInit, 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 } 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';
@ -11,8 +13,6 @@ 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 { ImageViewWithBlobUrl } from '../../../../../shared/client-models/ImageV
templateUrl: './image-select.html', templateUrl: './image-select.html',
styleUrl: './image-select.css', styleUrl: './image-select.css',
}) })
export class ImageSelect implements OnInit { export class ImageSelect {
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,49 +43,37 @@ export class ImageSelect implements OnInit {
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
protected readonly loadingImages = signal(false); protected readonly imageViewsQuery = injectQuery(() => ({
protected readonly loadImagesError = signal<Error | null>(null); queryKey: ['image-views', this.currentPage(), this.pageSize()],
protected readonly imagesSlice = signal<SliceView<ImageViewWithBlobUrl> | null>(null); queryFn: () =>
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({ }),
next: (sliceView) => { placeholderData: keepPreviousData,
sliceView.content.sort((a, b) => b.created.valueOf() - a.created.valueOf()); }));
this.loadingImages.set(false);
this.imagesSlice.set(sliceView); protected readonly imageCount = computed(() => {
}, if (this.imageViewsQuery.isSuccess()) {
error: (e) => { return this.imageViewsQuery.data()!.count;
this.loadingImages.set(false); } else {
this.loadImagesError.set(e); return 0;
},
});
this.imageService.getOwnedImagesCount().subscribe({
next: (count) => {
this.imageCount.set(count);
},
});
} }
});
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),
})) ?? [],
}));
protected onPage(pageEvent: PageEvent): void { protected onPage(pageEvent: PageEvent): void {
if (pageEvent.pageIndex < this.currentPage()) { if (pageEvent.pageIndex < this.currentPage()) {
@ -93,10 +81,9 @@ export class ImageSelect implements OnInit {
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.imagesSlice()?.slice.hasNext ? old + 1 : old)); this.currentPage.update((old) => (this.imageViewsQuery.data()?.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 {
@ -122,18 +109,8 @@ export class ImageSelect implements OnInit {
}); });
} }
protected deleteImage(imageView: ImageView): void { protected async deleteImage(imageView: ImageView): Promise<void> {
this.deleting.set(true); await this.imageService.deleteImage(imageView.owner.username, imageView.filename);
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;

View File

@ -2,7 +2,6 @@ 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> {

View File

@ -33,11 +33,6 @@ 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) {

View File

@ -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, from, map, mergeMap, Observable, tap, toArray } from 'rxjs'; import { firstValueFrom, map, Observable, 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';
@ -41,53 +41,10 @@ export class ImageService {
}; };
} }
public getOwnedImageViewsWithBlobUrls( public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
queryParams?: QueryParams<typeof ImageService.ImageProps>, return firstValueFrom(
): Observable<SliceView<ImageViewWithBlobUrl>> { this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams)),
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> {
@ -163,8 +120,19 @@ export class ImageService {
); );
} }
public deleteImage(username: string, filename: string): Observable<void> { public deleteImage(username: string, filename: string): Promise<void> {
return this.httpClient.delete<void>(this.endpointService.getUrl('images', [username, filename])); 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 updateImage(username: string, filename: string, data: ImageUpdateBody): Observable<ImageView> { public updateImage(username: string, filename: string, data: ImageUpdateBody): Observable<ImageView> {