MME-8 Add image select, WIP.
This commit is contained in:
parent
9040a61edb
commit
b07a8fd90b
@ -87,6 +87,9 @@
|
||||
<h3>Images</h3>
|
||||
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
|
||||
|
||||
<h4>Select Main Image</h4>
|
||||
<app-image-select (select)="onMainImageSelect($event)" [selectedUsernameFilename]="mainImageUsernameFilename()"></app-image-select>
|
||||
|
||||
<h3>Recipe Text</h3>
|
||||
<mat-form-field>
|
||||
<mat-label>Recipe Text</mat-label>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
@ -37,6 +38,8 @@ import {
|
||||
import { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel';
|
||||
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { ImageSelect } from './image-select/image-select';
|
||||
import { ImageView } from '../../../../shared/models/ImageView.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-enter-recipe-data',
|
||||
@ -63,6 +66,7 @@ import { DatePipe } from '@angular/common';
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
DatePipe,
|
||||
ImageSelect,
|
||||
],
|
||||
templateUrl: './enter-recipe-data.html',
|
||||
styleUrl: './enter-recipe-data.css',
|
||||
@ -85,6 +89,16 @@ export class EnterRecipeData implements OnInit {
|
||||
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
||||
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
||||
|
||||
private readonly mainImage = signal<ImageView | null>(null);
|
||||
protected readonly mainImageUsernameFilename = computed(() => {
|
||||
const mainImage = this.mainImage();
|
||||
if (mainImage) {
|
||||
return [mainImage.owner.username, mainImage.filename] as const;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
public ngOnInit(): void {
|
||||
@ -209,6 +223,10 @@ export class EnterRecipeData implements OnInit {
|
||||
this.dialog.open(ImageUploadDialog);
|
||||
}
|
||||
|
||||
protected onMainImageSelect(imageView: ImageView | null): void {
|
||||
this.mainImage.set(imageView);
|
||||
}
|
||||
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
protected readonly faBars = faBars;
|
||||
}
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.image-grid > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.selected-image {
|
||||
border: 5px solid var(--primary-black);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
@if (imageViewsQuery.isLoading()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (imageViewsQuery.isError()) {
|
||||
<p>There was an error loading images.</p>
|
||||
} @else if (imageViewsQuery.isSuccess()) {
|
||||
<div class="image-grid">
|
||||
@for (imageQuery of imageQueries(); 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();
|
||||
<img
|
||||
[src]="imageData!.blobUrl"
|
||||
alt="imageData!.imageView.alt"
|
||||
(click)="onImageClick(imageData!.imageView)"
|
||||
[ngClass]="{ 'selected-image': isSelected(imageData!.imageView) }"
|
||||
>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<mat-paginator [length]="imageCount()" [pageSize]="pageSize()" [pageSizeOptions]="[3, 6, 9, 12]" (page)="onPage($event)"></mat-paginator>
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ImageSelect } from './image-select';
|
||||
|
||||
describe('ImageSelect', () => {
|
||||
let component: ImageSelect;
|
||||
let fixture: ComponentFixture<ImageSelect>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ImageSelect]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ImageSelect);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,92 @@
|
||||
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 } 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 { NgClass } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-image-select',
|
||||
imports: [MatPaginator, Spinner, NgClass],
|
||||
templateUrl: './image-select.html',
|
||||
styleUrl: './image-select.css',
|
||||
})
|
||||
export class ImageSelect {
|
||||
public readonly select = output<ImageView | null>();
|
||||
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
|
||||
|
||||
protected readonly currentPage = signal(0);
|
||||
protected readonly pageSize = signal(9);
|
||||
|
||||
private readonly imageService = inject(ImageService);
|
||||
|
||||
protected readonly imageViewsQuery = injectQuery(() => ({
|
||||
queryKey: ['image-views', this.currentPage(), this.pageSize()],
|
||||
queryFn: () =>
|
||||
this.imageService.getOwnedImages({
|
||||
page: this.currentPage(),
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly imageQueries = 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}) ?? [],
|
||||
}));
|
||||
|
||||
protected onPage(pageEvent: PageEvent): void {
|
||||
if (pageEvent.pageIndex < this.currentPage()) {
|
||||
// backward
|
||||
this.currentPage.update((old) => Math.max(old - 1, 0));
|
||||
} else {
|
||||
// forward
|
||||
this.currentPage.update((old) => (this.imageViewsQuery.data()?.slice.hasNext ? old + 1 : old));
|
||||
}
|
||||
this.pageSize.set(pageEvent.pageSize);
|
||||
}
|
||||
|
||||
protected onImageClick(imageView: ImageView): void {
|
||||
if (this.isSelected(imageView)) {
|
||||
this.select.emit(null);
|
||||
} else {
|
||||
this.select.emit(imageView);
|
||||
}
|
||||
}
|
||||
|
||||
protected isSelected(imageView: ImageView): boolean {
|
||||
const selectedUsernameFilename = this.selectedUsernameFilename();
|
||||
if (selectedUsernameFilename) {
|
||||
const [username, filename] = selectedUsernameFilename;
|
||||
return imageView.owner.username === username && imageView.filename === filename;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,9 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula
|
||||
import { RecipeService } from '../../services/RecipeService';
|
||||
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { RecipeComments } from '../../models/RecipeComment.model';
|
||||
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
|
||||
import { SliceView } from '../../models/SliceView.model';
|
||||
import { RecipeComment } from '../../models/RecipeComment.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-comments-list',
|
||||
@ -24,7 +25,7 @@ export class RecipeCommentsList {
|
||||
|
||||
protected commentsQuery = injectInfiniteQuery(() => ({
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (previousPage: RecipeComments) =>
|
||||
getNextPageParam: (previousPage: SliceView<RecipeComment>) =>
|
||||
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
|
||||
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
|
||||
queryFn: ({ pageParam }) => {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
export interface QueryParams {
|
||||
export interface QueryParams<T extends readonly string[] = any> {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: Array<string | Sort>;
|
||||
sort?: Array<string | Sort<T>>;
|
||||
}
|
||||
|
||||
export interface Sort {
|
||||
property: string;
|
||||
export interface Sort<T extends readonly string[] = any> {
|
||||
property: T[number];
|
||||
order?: 'ASC' | 'DESC';
|
||||
ignoreCase?: boolean;
|
||||
}
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
import { ResourceOwner } from './ResourceOwner.model';
|
||||
import { SliceView } from './SliceView.model';
|
||||
|
||||
export interface RecipeComments {
|
||||
slice: SliceView;
|
||||
content: RecipeComment[];
|
||||
}
|
||||
|
||||
export interface RecipeComment {
|
||||
id: number;
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
export interface SliceView {
|
||||
export interface SliceView<T> {
|
||||
content: T[];
|
||||
count: number;
|
||||
slice: SliceViewMeta;
|
||||
}
|
||||
|
||||
export interface SliceViewMeta {
|
||||
hasNext: boolean;
|
||||
number: number;
|
||||
size: number;
|
||||
|
||||
@ -7,7 +7,7 @@ import { environment } from '../../../environments/environment';
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EndpointService {
|
||||
public getUrl(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams): string {
|
||||
public getUrl<P extends readonly string[] = []>(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams<P>): string {
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
if (queryParams?.page !== undefined) {
|
||||
urlSearchParams.set('page', queryParams.page.toString());
|
||||
|
||||
@ -3,14 +3,37 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { EndpointService } from './EndpointService';
|
||||
import { ImageView } from '../models/ImageView.model';
|
||||
import { SliceView } from '../models/SliceView.model';
|
||||
import { QueryParams } from '../models/Query.model';;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ImageService {
|
||||
public static ImageProps = [
|
||||
'id',
|
||||
'created',
|
||||
'modified',
|
||||
'userFilename',
|
||||
'mimeType',
|
||||
'alt',
|
||||
'caption',
|
||||
'objectName',
|
||||
'height',
|
||||
'width',
|
||||
'owner',
|
||||
'viewers'
|
||||
] as const;
|
||||
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly endpointService = inject(EndpointService);
|
||||
|
||||
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
|
||||
return firstValueFrom(
|
||||
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: this api should not accept null as an input
|
||||
*/
|
||||
|
||||
@ -4,9 +4,10 @@ import { firstValueFrom, lastValueFrom, map } from 'rxjs';
|
||||
import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model';
|
||||
import { AuthService } from './AuthService';
|
||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
||||
import { RecipeComment, RecipeComments } from '../models/RecipeComment.model';
|
||||
import { RecipeComment } from '../models/RecipeComment.model';
|
||||
import { QueryParams } from '../models/Query.model';
|
||||
import { EndpointService } from './EndpointService';
|
||||
import { SliceView } from '../models/SliceView.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@ -46,9 +47,9 @@ export class RecipeService {
|
||||
}
|
||||
}
|
||||
|
||||
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<RecipeComments> {
|
||||
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<SliceView<RecipeComment>> {
|
||||
return firstValueFrom(
|
||||
this.http.get<RecipeComments>(
|
||||
this.http.get<SliceView<RecipeComment>>(
|
||||
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
|
||||
),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user