MME-7 Add image upload dialog to recipe data entry page.

This commit is contained in:
Jesse Brault 2026-02-04 17:52:03 -06:00
parent aa28f8d100
commit 80b0c5e4d5
15 changed files with 288 additions and 8 deletions

View File

@ -2,6 +2,7 @@ export const Endpoints = {
authLogin: 'auth/login',
authLogout: 'auth/logout',
authRefresh: 'auth/refresh',
images: 'images',
recipes: 'recipes',
recipeDrafts: 'recipe-drafts',
};

View File

@ -79,6 +79,9 @@
</table>
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
<h3>Images</h3>
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
<h3>Recipe Text</h3>
<mat-form-field>
<mat-label>Recipe Text</mat-label>

View File

@ -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<EnterRecipeDataSubmitEvent>();
public readonly deleteDraft = output<void>();
protected recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
protected ingredientsTable = viewChild.required<
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
protected readonly ingredientsTable = viewChild.required<
MatTable<
FormGroup<{
amount: FormControl<string | null>;
@ -73,7 +75,9 @@ export class EnterRecipeData implements OnInit {
}>
>
>('ingredientsTable');
protected ingredientAmountControls = viewChildren<ElementRef<HTMLInputElement>>('ingredientAmount');
protected readonly ingredientAmountControls = viewChildren<ElementRef<HTMLInputElement>>('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;
}

View File

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

View File

@ -0,0 +1,49 @@
<div class="image-upload-dialog-content">
<h3>Image Upload</h3>
<form>
@let file = fileToUpload();
@if (file !== null) {
<app-file-upload
[files]="[file]"
(fileChange)="onImageFileEvent($event)"
[iconDefinition]="faFileImage"
></app-file-upload>
} @else {
<app-file-upload
[files]="[]"
(fileChange)="onImageFileEvent($event)"
[iconDefinition]="faFileImage"
></app-file-upload>
}
<mat-form-field>
<mat-label>Filename</mat-label>
<input matInput [formControl]="imageUploadForm.controls.filename" />
@if (imageUploadForm.controls.filename.hasError("imageExists")) {
<mat-error>Filename taken. Choose a different name.</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Alt</mat-label>
<input matInput [formControl]="imageUploadForm.controls.alt" />
</mat-form-field>
<mat-form-field>
<mat-label>Caption</mat-label>
<input matInput [formControl]="imageUploadForm.controls.caption" />
</mat-form-field>
<mat-checkbox [formControl]="imageUploadForm.controls.isPublic">Is Public?</mat-checkbox>
@if (error()) {
<p>{{ error() }}</p>
}
<button
matButton="filled"
(click)="onSubmit()"
[disabled]="submitDisabled() || imageUploadForm.invalid"
type="button"
>
<div>
<span>Upload</span>
<app-spinner [enabled]="inProgress()" size="24px"></app-spinner>
</div>
</button>
</form>
</div>

View File

@ -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<ImageUploadDialog>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ImageUploadDialog],
}).compileComponents();
fixture = TestBed.createComponent(ImageUploadDialog);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<File | null>(null);
protected readonly inProgress = signal(false);
protected readonly submitDisabled = computed(() => !this.fileToUpload() || this.inProgress());
protected readonly error = signal<string | null>(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<void> {
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;
}

View File

@ -12,3 +12,8 @@
fa-icon {
cursor: pointer;
}
button {
border: none;
background: none;
}

View File

@ -1,6 +1,14 @@
<input #fileInput type="file" (change)="onFileChange($event)" style="display: none" />
<input
#fileInput
type="file"
(change)="onFileChange($event)"
style="display: none"
[multiple]="mode() === 'multiple'"
/>
<div class="file-input-container">
<fa-icon [icon]="faFileUpload" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
<button type="button">
<fa-icon [icon]="iconDefinition()" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
</button>
@if (fileNames().length) {
@for (fileName of fileNames(); track $index) {
<p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p>

View File

@ -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<File[]>([]);
public readonly mode = input<'single' | 'multiple'>('single');
public readonly iconDefinition = input<IconDefinition>(faFileUpload);
public readonly fileChange = output<FileUploadEvent>();
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;
}

View File

@ -1,3 +1,3 @@
@if (enabled()) {
<span class="loader"></span>
<span class="loader" [style]="{ width: size(), height: size() }"></span>
}

View File

@ -8,4 +8,5 @@ import { Component, input } from '@angular/core';
})
export class Spinner {
public readonly enabled = input(true);
public readonly size = input('48px');
}

View File

@ -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<string | null> {
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<ImageView> {
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<ImageView>(this.endpointService.getUrl('images'), formData));
}
public imageExists(username: string, filename: string): Promise<boolean> {
return firstValueFrom(
this.httpClient
.get<{ exists: boolean }>(this.endpointService.getUrl('images', [username, filename, 'exists']))
.pipe(map((view) => view.exists)),
);
}
}

View File

@ -0,0 +1,7 @@
import { ImageDoesNotExistValidator } from './image-does-not-exist-validator';
describe('ImageUsernameFilenameValidator', () => {
it('should create an instance', () => {
expect(new ImageDoesNotExistValidator()).toBeTruthy();
});
});

View File

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