diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html index d99ab15..6618532 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html @@ -87,6 +87,9 @@

Images

+

Select Main Image

+ +

Recipe Text

Recipe Text diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts index e57691b..87082e4 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts @@ -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>('ingredientsTable'); protected readonly ingredientModels = signal([]); + private readonly mainImage = signal(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; } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css new file mode 100644 index 0000000..bde5764 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.css @@ -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; +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html new file mode 100644 index 0000000..c653765 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.html @@ -0,0 +1,24 @@ +@if (imageViewsQuery.isLoading()) { + +} @else if (imageViewsQuery.isError()) { +

There was an error loading images.

+} @else if (imageViewsQuery.isSuccess()) { +
+ @for (imageQuery of imageQueries(); track $index) { + @if (imageQuery.isLoading()) { + + } @else if (imageQuery.isError()) { +

There was an error loading this image.

+ } @else { + @let imageData = imageQuery.data(); + imageData!.imageView.alt + } + } +
+ +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.spec.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.spec.ts new file mode 100644 index 0000000..41a18e3 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ImageSelect } from './image-select'; + +describe('ImageSelect', () => { + let component: ImageSelect; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ImageSelect] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ImageSelect); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts new file mode 100644 index 0000000..3ed45f3 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select.ts @@ -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(); + public readonly selectedUsernameFilename = input(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; + } +} diff --git a/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts b/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts index d6189df..08cc76c 100644 --- a/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts +++ b/src/app/shared/components/recipe-comments-list/recipe-comments-list.ts @@ -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) => previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined, queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()], queryFn: ({ pageParam }) => { diff --git a/src/app/shared/models/Query.model.ts b/src/app/shared/models/Query.model.ts index 237a1fe..fc2b331 100644 --- a/src/app/shared/models/Query.model.ts +++ b/src/app/shared/models/Query.model.ts @@ -1,11 +1,11 @@ -export interface QueryParams { +export interface QueryParams { page?: number; size?: number; - sort?: Array; + sort?: Array>; } -export interface Sort { - property: string; +export interface Sort { + property: T[number]; order?: 'ASC' | 'DESC'; ignoreCase?: boolean; } diff --git a/src/app/shared/models/RecipeComment.model.ts b/src/app/shared/models/RecipeComment.model.ts index efeab1c..a162870 100644 --- a/src/app/shared/models/RecipeComment.model.ts +++ b/src/app/shared/models/RecipeComment.model.ts @@ -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; diff --git a/src/app/shared/models/SliceView.model.ts b/src/app/shared/models/SliceView.model.ts index 7de685c..435e450 100644 --- a/src/app/shared/models/SliceView.model.ts +++ b/src/app/shared/models/SliceView.model.ts @@ -1,4 +1,10 @@ -export interface SliceView { +export interface SliceView { + content: T[]; + count: number; + slice: SliceViewMeta; +} + +export interface SliceViewMeta { hasNext: boolean; number: number; size: number; diff --git a/src/app/shared/services/EndpointService.ts b/src/app/shared/services/EndpointService.ts index f8878f3..6e82292 100644 --- a/src/app/shared/services/EndpointService.ts +++ b/src/app/shared/services/EndpointService.ts @@ -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

(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams

): string { const urlSearchParams = new URLSearchParams(); if (queryParams?.page !== undefined) { urlSearchParams.set('page', queryParams.page.toString()); diff --git a/src/app/shared/services/ImageService.ts b/src/app/shared/services/ImageService.ts index 2784ca7..f7bab37 100644 --- a/src/app/shared/services/ImageService.ts +++ b/src/app/shared/services/ImageService.ts @@ -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): Promise> { + return firstValueFrom( + this.httpClient.get>(this.endpointService.getUrl('images', [], queryParams)) + ); + } + /** * TODO: this api should not accept null as an input */ diff --git a/src/app/shared/services/RecipeService.ts b/src/app/shared/services/RecipeService.ts index fee7ee5..01c5fac 100644 --- a/src/app/shared/services/RecipeService.ts +++ b/src/app/shared/services/RecipeService.ts @@ -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 { + public getComments(username: string, slug: string, queryParams?: QueryParams): Promise> { return firstValueFrom( - this.http.get( + this.http.get>( this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams), ), );