MME-8 WIP refactoring queries for image views, small style. TODO: mutations.
This commit is contained in:
parent
76ed65a144
commit
7c483ba5f6
@ -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>
|
||||
|
||||
@ -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(() => ({
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
mat-card-content p {
|
||||
.image-filename {
|
||||
font-size: 0.75em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
5
src/app/shared/client-models/ImageViewWithBlobUrl.ts
Normal file
5
src/app/shared/client-models/ImageViewWithBlobUrl.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { ImageView } from '../models/ImageView.model';
|
||||
|
||||
export interface ImageViewWithBlobUrl extends ImageView {
|
||||
blobUrl: string;
|
||||
}
|
||||
@ -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()">
|
||||
|
||||
@ -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]
|
||||
>;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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],
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user