Compare commits
2 Commits
352110a372
...
64b3cf92f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b3cf92f7 | ||
|
|
8c0a4dd9f4 |
@ -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()"
|
||||
|
||||
@ -1,14 +1,36 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
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', () => {
|
||||
let component: 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 () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ImageSelect],
|
||||
providers: [
|
||||
{
|
||||
provide: ImageService,
|
||||
useValue: imageService,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ImageSelect);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user