MME-8 Add image select, WIP.

This commit is contained in:
Jesse Brault 2026-02-05 18:34:40 -06:00
parent 9040a61edb
commit b07a8fd90b
13 changed files with 219 additions and 17 deletions

View File

@ -87,6 +87,9 @@
<h3>Images</h3> <h3>Images</h3>
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button> <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> <h3>Recipe Text</h3>
<mat-form-field> <mat-form-field>
<mat-label>Recipe Text</mat-label> <mat-label>Recipe Text</mat-label>

View File

@ -1,6 +1,7 @@
import { import {
afterNextRender, afterNextRender,
Component, Component,
computed,
ElementRef, ElementRef,
inject, inject,
Injector, Injector,
@ -37,6 +38,8 @@ import {
import { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel'; import { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { ImageSelect } from './image-select/image-select';
import { ImageView } from '../../../../shared/models/ImageView.model';
@Component({ @Component({
selector: 'app-enter-recipe-data', selector: 'app-enter-recipe-data',
@ -63,6 +66,7 @@ import { DatePipe } from '@angular/common';
CdkDropList, CdkDropList,
CdkDrag, CdkDrag,
DatePipe, DatePipe,
ImageSelect,
], ],
templateUrl: './enter-recipe-data.html', templateUrl: './enter-recipe-data.html',
styleUrl: './enter-recipe-data.css', styleUrl: './enter-recipe-data.css',
@ -85,6 +89,16 @@ export class EnterRecipeData implements OnInit {
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable'); protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]); 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); private readonly injector = inject(Injector);
public ngOnInit(): void { public ngOnInit(): void {
@ -209,6 +223,10 @@ export class EnterRecipeData implements OnInit {
this.dialog.open(ImageUploadDialog); this.dialog.open(ImageUploadDialog);
} }
protected onMainImageSelect(imageView: ImageView | null): void {
this.mainImage.set(imageView);
}
protected readonly faEllipsis = faEllipsis; protected readonly faEllipsis = faEllipsis;
protected readonly faBars = faBars; protected readonly faBars = faBars;
} }

View File

@ -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;
}

View File

@ -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>
}

View File

@ -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();
});
});

View File

@ -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;
}
}

View File

@ -3,8 +3,9 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula
import { RecipeService } from '../../services/RecipeService'; import { RecipeService } from '../../services/RecipeService';
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental'; import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
import { RecipeComments } from '../../models/RecipeComment.model';
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe'; import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
import { SliceView } from '../../models/SliceView.model';
import { RecipeComment } from '../../models/RecipeComment.model';
@Component({ @Component({
selector: 'app-recipe-comments-list', selector: 'app-recipe-comments-list',
@ -24,7 +25,7 @@ export class RecipeCommentsList {
protected commentsQuery = injectInfiniteQuery(() => ({ protected commentsQuery = injectInfiniteQuery(() => ({
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (previousPage: RecipeComments) => getNextPageParam: (previousPage: SliceView<RecipeComment>) =>
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined, previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()], queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
queryFn: ({ pageParam }) => { queryFn: ({ pageParam }) => {

View File

@ -1,11 +1,11 @@
export interface QueryParams { export interface QueryParams<T extends readonly string[] = any> {
page?: number; page?: number;
size?: number; size?: number;
sort?: Array<string | Sort>; sort?: Array<string | Sort<T>>;
} }
export interface Sort { export interface Sort<T extends readonly string[] = any> {
property: string; property: T[number];
order?: 'ASC' | 'DESC'; order?: 'ASC' | 'DESC';
ignoreCase?: boolean; ignoreCase?: boolean;
} }

View File

@ -1,10 +1,4 @@
import { ResourceOwner } from './ResourceOwner.model'; import { ResourceOwner } from './ResourceOwner.model';
import { SliceView } from './SliceView.model';
export interface RecipeComments {
slice: SliceView;
content: RecipeComment[];
}
export interface RecipeComment { export interface RecipeComment {
id: number; id: number;

View File

@ -1,4 +1,10 @@
export interface SliceView { export interface SliceView<T> {
content: T[];
count: number;
slice: SliceViewMeta;
}
export interface SliceViewMeta {
hasNext: boolean; hasNext: boolean;
number: number; number: number;
size: number; size: number;

View File

@ -7,7 +7,7 @@ import { environment } from '../../../environments/environment';
providedIn: 'root', providedIn: 'root',
}) })
export class EndpointService { 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(); const urlSearchParams = new URLSearchParams();
if (queryParams?.page !== undefined) { if (queryParams?.page !== undefined) {
urlSearchParams.set('page', queryParams.page.toString()); urlSearchParams.set('page', queryParams.page.toString());

View File

@ -3,14 +3,37 @@ import { HttpClient } from '@angular/common/http';
import { firstValueFrom, map } from 'rxjs'; import { firstValueFrom, map } 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 { QueryParams } from '../models/Query.model';;
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ImageService { 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 httpClient = inject(HttpClient);
private readonly endpointService = inject(EndpointService); 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 * TODO: this api should not accept null as an input
*/ */

View File

@ -4,9 +4,10 @@ import { firstValueFrom, lastValueFrom, map } from 'rxjs';
import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model'; import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model';
import { AuthService } from './AuthService'; import { AuthService } from './AuthService';
import { QueryClient } from '@tanstack/angular-query-experimental'; 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 { QueryParams } from '../models/Query.model';
import { EndpointService } from './EndpointService'; import { EndpointService } from './EndpointService';
import { SliceView } from '../models/SliceView.model';
@Injectable({ @Injectable({
providedIn: 'root', 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( return firstValueFrom(
this.http.get<RecipeComments>( this.http.get<SliceView<RecipeComment>>(
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams), this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
), ),
); );