From 80b0c5e4d58aec8151e810db4b1730c5e6be141e Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Wed, 4 Feb 2026 17:52:03 -0600 Subject: [PATCH] MME-7 Add image upload dialog to recipe data entry page. --- src/app/endpoints.ts | 1 + .../enter-recipe-data/enter-recipe-data.html | 3 + .../enter-recipe-data/enter-recipe-data.ts | 14 ++- .../image-upload-dialog.css | 23 +++++ .../image-upload-dialog.html | 49 +++++++++++ .../image-upload-dialog.spec.ts | 22 +++++ .../image-upload-dialog.ts | 87 +++++++++++++++++++ .../components/file-upload/file-upload.css | 5 ++ .../components/file-upload/file-upload.html | 12 ++- .../components/file-upload/file-upload.ts | 5 +- .../shared/components/spinner/spinner.html | 2 +- src/app/shared/components/spinner/spinner.ts | 1 + src/app/shared/services/ImageService.ts | 37 ++++++++ .../image-does-not-exist-validator.spec.ts | 7 ++ .../image-does-not-exist-validator.ts | 28 ++++++ 15 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.css create mode 100644 src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html create mode 100644 src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.spec.ts create mode 100644 src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts create mode 100644 src/app/shared/validators/image-does-not-exist-validator.spec.ts create mode 100644 src/app/shared/validators/image-does-not-exist-validator.ts diff --git a/src/app/endpoints.ts b/src/app/endpoints.ts index 42b2aea..733ab6e 100644 --- a/src/app/endpoints.ts +++ b/src/app/endpoints.ts @@ -2,6 +2,7 @@ export const Endpoints = { authLogin: 'auth/login', authLogout: 'auth/logout', authRefresh: 'auth/refresh', + images: 'images', recipes: 'recipes', recipeDrafts: 'recipe-drafts', }; 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 99f9c72..3ba338f 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 @@ -79,6 +79,9 @@ +

Images

+ +

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 3456af0..e424e9e 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 @@ -31,6 +31,8 @@ import { import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { MatDialog } from '@angular/material/dialog'; +import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog'; @Component({ selector: 'app-enter-recipe-data', @@ -63,8 +65,8 @@ export class EnterRecipeData implements OnInit { public readonly submit = output(); public readonly deleteDraft = output(); - protected recipeTextTextarea = viewChild.required>('recipeTextTextarea'); - protected ingredientsTable = viewChild.required< + protected readonly recipeTextTextarea = viewChild.required>('recipeTextTextarea'); + protected readonly ingredientsTable = viewChild.required< MatTable< FormGroup<{ amount: FormControl; @@ -73,7 +75,9 @@ export class EnterRecipeData implements OnInit { }> > >('ingredientsTable'); - protected ingredientAmountControls = viewChildren>('ingredientAmount'); + protected readonly ingredientAmountControls = viewChildren>('ingredientAmount'); + + private readonly dialog = inject(MatDialog); protected readonly recipeFormGroup = new FormGroup({ title: new FormControl('', Validators.required), @@ -188,5 +192,9 @@ export class EnterRecipeData implements OnInit { this.deleteDraft.emit(); } + protected openImageUploadDialog(): void { + this.dialog.open(ImageUploadDialog); + } + protected readonly faEllipsis = faEllipsis; } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.css new file mode 100644 index 0000000..ba08209 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.css @@ -0,0 +1,23 @@ +.image-upload-dialog-content { + padding: 1rem; + display: flex; + flex-direction: column; + row-gap: 1rem; +} + +form { + display: flex; + flex-direction: column; + justify-content: flex-start; + row-gap: 20px; +} + +button { + width: 100%; +} + +button div { + display: flex; + column-gap: 10px; + align-items: center; +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html new file mode 100644 index 0000000..1fc2921 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html @@ -0,0 +1,49 @@ +
+

Image Upload

+
+ @let file = fileToUpload(); + @if (file !== null) { + + } @else { + + } + + Filename + + @if (imageUploadForm.controls.filename.hasError("imageExists")) { + Filename taken. Choose a different name. + } + + + Alt + + + + Caption + + + Is Public? + @if (error()) { +

{{ error() }}

+ } + +
+
diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.spec.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.spec.ts new file mode 100644 index 0000000..c4a781d --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ImageUploadDialog } from './image-upload-dialog'; + +describe('ImageUploadDialog', () => { + let component: ImageUploadDialog; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ImageUploadDialog], + }).compileComponents(); + + fixture = TestBed.createComponent(ImageUploadDialog); + 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-upload-dialog/image-upload-dialog.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts new file mode 100644 index 0000000..a0833ad --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts @@ -0,0 +1,87 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { ImageService } from '../../../../../shared/services/ImageService'; +import { FileUpload } from '../../../../../shared/components/file-upload/file-upload'; +import { MatButton } from '@angular/material/button'; +import { faFileImage } from '@fortawesome/free-solid-svg-icons'; +import { FileUploadEvent } from '../../../../../shared/components/file-upload/FileUploadEvent'; +import { Spinner } from '../../../../../shared/components/spinner/spinner'; +import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialogRef } from '@angular/material/dialog'; +import { ImageDoesNotExistValidator } from '../../../../../shared/validators/image-does-not-exist-validator'; + +@Component({ + selector: 'app-image-upload-dialog', + imports: [ + FileUpload, + MatButton, + Spinner, + MatFormField, + MatLabel, + MatInput, + ReactiveFormsModule, + MatCheckbox, + MatError, + ], + templateUrl: './image-upload-dialog.html', + styleUrl: './image-upload-dialog.css', +}) +export class ImageUploadDialog { + public readonly dialogRef = inject(MatDialogRef); + + protected readonly fileToUpload = signal(null); + protected readonly inProgress = signal(false); + protected readonly submitDisabled = computed(() => !this.fileToUpload() || this.inProgress()); + protected readonly error = signal(null); + + private readonly imageDoesNotExistValidator = inject(ImageDoesNotExistValidator); + + protected readonly imageUploadForm = new FormGroup({ + filename: new FormControl('', { + validators: Validators.required, + asyncValidators: this.imageDoesNotExistValidator.validator(), + updateOn: 'blur', + }), + alt: new FormControl(''), + caption: new FormControl(''), + isPublic: new FormControl(true), + }); + + private readonly imageService = inject(ImageService); + + protected onImageFileEvent(event: FileUploadEvent): void { + if (event._tag === 'file-add-event') { + this.fileToUpload.set(event.file); + this.imageUploadForm.controls.filename.setValue(event.file.name); + this.imageUploadForm.controls.filename.markAsTouched(); + } else { + this.fileToUpload.set(null); + this.imageUploadForm.reset(); + } + } + + protected async onSubmit(): Promise { + this.inProgress.set(true); + const formValue = this.imageUploadForm.value; + let hadError = false; + try { + await this.imageService.uploadImage( + this.fileToUpload()!, // submit disabled if this is null + formValue.filename ?? undefined, + formValue.alt ?? undefined, + formValue.caption ?? undefined, + formValue.isPublic ?? undefined, + ); + } catch (e) { + this.error.set('There was an error during upload. Try again.'); + hadError = true; + } + this.inProgress.set(false); + if (!hadError) { + this.dialogRef.close(); + } + } + + protected readonly faFileImage = faFileImage; +} diff --git a/src/app/shared/components/file-upload/file-upload.css b/src/app/shared/components/file-upload/file-upload.css index 8d6a06e..707eec1 100644 --- a/src/app/shared/components/file-upload/file-upload.css +++ b/src/app/shared/components/file-upload/file-upload.css @@ -12,3 +12,8 @@ fa-icon { cursor: pointer; } + +button { + border: none; + background: none; +} diff --git a/src/app/shared/components/file-upload/file-upload.html b/src/app/shared/components/file-upload/file-upload.html index c2a2b96..ea07b74 100644 --- a/src/app/shared/components/file-upload/file-upload.html +++ b/src/app/shared/components/file-upload/file-upload.html @@ -1,6 +1,14 @@ - +
- + @if (fileNames().length) { @for (fileName of fileNames(); track $index) {

{{ fileName }}

diff --git a/src/app/shared/components/file-upload/file-upload.ts b/src/app/shared/components/file-upload/file-upload.ts index 03815c7..f9d73bd 100644 --- a/src/app/shared/components/file-upload/file-upload.ts +++ b/src/app/shared/components/file-upload/file-upload.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { FaIconComponent, IconDefinition } from '@fortawesome/angular-fontawesome'; import { faCancel, faFileUpload } from '@fortawesome/free-solid-svg-icons'; import { FileUploadEvent } from './FileUploadEvent'; @@ -11,6 +11,8 @@ import { FileUploadEvent } from './FileUploadEvent'; }) export class FileUpload { public readonly files = input([]); + public readonly mode = input<'single' | 'multiple'>('single'); + public readonly iconDefinition = input(faFileUpload); public readonly fileChange = output(); protected fileNames = computed(() => this.files().map((file) => file.name)); @@ -37,6 +39,5 @@ export class FileUpload { fileInput.value = ''; } - protected readonly faFileUpload = faFileUpload; protected readonly faCancel = faCancel; } diff --git a/src/app/shared/components/spinner/spinner.html b/src/app/shared/components/spinner/spinner.html index d20c385..01f3337 100644 --- a/src/app/shared/components/spinner/spinner.html +++ b/src/app/shared/components/spinner/spinner.html @@ -1,3 +1,3 @@ @if (enabled()) { - + } diff --git a/src/app/shared/components/spinner/spinner.ts b/src/app/shared/components/spinner/spinner.ts index 0ebdc0c..e915f2f 100644 --- a/src/app/shared/components/spinner/spinner.ts +++ b/src/app/shared/components/spinner/spinner.ts @@ -8,4 +8,5 @@ import { Component, input } from '@angular/core'; }) export class Spinner { public readonly enabled = input(true); + public readonly size = input('48px'); } diff --git a/src/app/shared/services/ImageService.ts b/src/app/shared/services/ImageService.ts index 1596331..2784ca7 100644 --- a/src/app/shared/services/ImageService.ts +++ b/src/app/shared/services/ImageService.ts @@ -1,13 +1,19 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom, map } from 'rxjs'; +import { EndpointService } from './EndpointService'; +import { ImageView } from '../models/ImageView.model'; @Injectable({ providedIn: 'root', }) export class ImageService { private readonly httpClient = inject(HttpClient); + private readonly endpointService = inject(EndpointService); + /** + * TODO: this api should not accept null as an input + */ public getImage(backendUrl?: string | null): Promise { if (!!backendUrl) { return firstValueFrom( @@ -21,4 +27,35 @@ export class ImageService { return Promise.resolve(null); } } + + public uploadImage( + image: File, + filename?: string, + alt?: string, + caption?: string, + isPublic?: boolean, + ): Promise { + const formData = new FormData(); + formData.append('image', image); + formData.append('filename', filename ?? image.name); + if (alt) { + formData.append('alt', alt); + } + if (caption) { + formData.append('caption', caption); + } + if (isPublic !== undefined) { + formData.append('isPublic', isPublic.toString()); + } + + return firstValueFrom(this.httpClient.post(this.endpointService.getUrl('images'), formData)); + } + + public imageExists(username: string, filename: string): Promise { + return firstValueFrom( + this.httpClient + .get<{ exists: boolean }>(this.endpointService.getUrl('images', [username, filename, 'exists'])) + .pipe(map((view) => view.exists)), + ); + } } diff --git a/src/app/shared/validators/image-does-not-exist-validator.spec.ts b/src/app/shared/validators/image-does-not-exist-validator.spec.ts new file mode 100644 index 0000000..eda686e --- /dev/null +++ b/src/app/shared/validators/image-does-not-exist-validator.spec.ts @@ -0,0 +1,7 @@ +import { ImageDoesNotExistValidator } from './image-does-not-exist-validator'; + +describe('ImageUsernameFilenameValidator', () => { + it('should create an instance', () => { + expect(new ImageDoesNotExistValidator()).toBeTruthy(); + }); +}); diff --git a/src/app/shared/validators/image-does-not-exist-validator.ts b/src/app/shared/validators/image-does-not-exist-validator.ts new file mode 100644 index 0000000..ceb6ce7 --- /dev/null +++ b/src/app/shared/validators/image-does-not-exist-validator.ts @@ -0,0 +1,28 @@ +import { inject, Injectable } from '@angular/core'; +import { ImageService } from '../services/ImageService'; +import { AsyncValidatorFn } from '@angular/forms'; +import { AuthService } from '../services/AuthService'; + +@Injectable({ + providedIn: 'root', +}) +export class ImageDoesNotExistValidator { + private readonly imageService = inject(ImageService); + private readonly authService = inject(AuthService); + + public validator(): AsyncValidatorFn { + return async (control) => { + const username = this.authService.username(); + if (!username) { + throw new Error('Must be logged in.'); + } + if (await this.imageService.imageExists(username, control.value)) { + return { + imageExists: true, + }; + } else { + return null; + } + }; + } +}