MME-7 Add image upload dialog to recipe data entry page.
This commit is contained in:
parent
aa28f8d100
commit
80b0c5e4d5
@ -2,6 +2,7 @@ export const Endpoints = {
|
|||||||
authLogin: 'auth/login',
|
authLogin: 'auth/login',
|
||||||
authLogout: 'auth/logout',
|
authLogout: 'auth/logout',
|
||||||
authRefresh: 'auth/refresh',
|
authRefresh: 'auth/refresh',
|
||||||
|
images: 'images',
|
||||||
recipes: 'recipes',
|
recipes: 'recipes',
|
||||||
recipeDrafts: 'recipe-drafts',
|
recipeDrafts: 'recipe-drafts',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -79,6 +79,9 @@
|
|||||||
</table>
|
</table>
|
||||||
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
|
<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>
|
<h3>Recipe Text</h3>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Recipe Text</mat-label>
|
<mat-label>Recipe Text</mat-label>
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import {
|
|||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-enter-recipe-data',
|
selector: 'app-enter-recipe-data',
|
||||||
@ -63,8 +65,8 @@ export class EnterRecipeData implements OnInit {
|
|||||||
public readonly submit = output<EnterRecipeDataSubmitEvent>();
|
public readonly submit = output<EnterRecipeDataSubmitEvent>();
|
||||||
public readonly deleteDraft = output<void>();
|
public readonly deleteDraft = output<void>();
|
||||||
|
|
||||||
protected recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
||||||
protected ingredientsTable = viewChild.required<
|
protected readonly ingredientsTable = viewChild.required<
|
||||||
MatTable<
|
MatTable<
|
||||||
FormGroup<{
|
FormGroup<{
|
||||||
amount: FormControl<string | null>;
|
amount: FormControl<string | null>;
|
||||||
@ -73,7 +75,9 @@ export class EnterRecipeData implements OnInit {
|
|||||||
}>
|
}>
|
||||||
>
|
>
|
||||||
>('ingredientsTable');
|
>('ingredientsTable');
|
||||||
protected ingredientAmountControls = viewChildren<ElementRef<HTMLInputElement>>('ingredientAmount');
|
protected readonly ingredientAmountControls = viewChildren<ElementRef<HTMLInputElement>>('ingredientAmount');
|
||||||
|
|
||||||
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
protected readonly recipeFormGroup = new FormGroup({
|
protected readonly recipeFormGroup = new FormGroup({
|
||||||
title: new FormControl('', Validators.required),
|
title: new FormControl('', Validators.required),
|
||||||
@ -188,5 +192,9 @@ export class EnterRecipeData implements OnInit {
|
|||||||
this.deleteDraft.emit();
|
this.deleteDraft.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected openImageUploadDialog(): void {
|
||||||
|
this.dialog.open(ImageUploadDialog);
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly faEllipsis = faEllipsis;
|
protected readonly faEllipsis = faEllipsis;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -12,3 +12,8 @@
|
|||||||
fa-icon {
|
fa-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|||||||
@ -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">
|
<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) {
|
@if (fileNames().length) {
|
||||||
@for (fileName of fileNames(); track $index) {
|
@for (fileName of fileNames(); track $index) {
|
||||||
<p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p>
|
<p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
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 { faCancel, faFileUpload } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FileUploadEvent } from './FileUploadEvent';
|
import { FileUploadEvent } from './FileUploadEvent';
|
||||||
|
|
||||||
@ -11,6 +11,8 @@ import { FileUploadEvent } from './FileUploadEvent';
|
|||||||
})
|
})
|
||||||
export class FileUpload {
|
export class FileUpload {
|
||||||
public readonly files = input<File[]>([]);
|
public readonly files = input<File[]>([]);
|
||||||
|
public readonly mode = input<'single' | 'multiple'>('single');
|
||||||
|
public readonly iconDefinition = input<IconDefinition>(faFileUpload);
|
||||||
public readonly fileChange = output<FileUploadEvent>();
|
public readonly fileChange = output<FileUploadEvent>();
|
||||||
|
|
||||||
protected fileNames = computed(() => this.files().map((file) => file.name));
|
protected fileNames = computed(() => this.files().map((file) => file.name));
|
||||||
@ -37,6 +39,5 @@ export class FileUpload {
|
|||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly faFileUpload = faFileUpload;
|
|
||||||
protected readonly faCancel = faCancel;
|
protected readonly faCancel = faCancel;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
@if (enabled()) {
|
@if (enabled()) {
|
||||||
<span class="loader"></span>
|
<span class="loader" [style]="{ width: size(), height: size() }"></span>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,5 @@ import { Component, input } from '@angular/core';
|
|||||||
})
|
})
|
||||||
export class Spinner {
|
export class Spinner {
|
||||||
public readonly enabled = input(true);
|
public readonly enabled = input(true);
|
||||||
|
public readonly size = input('48px');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { firstValueFrom, map } from 'rxjs';
|
import { firstValueFrom, map } from 'rxjs';
|
||||||
|
import { EndpointService } from './EndpointService';
|
||||||
|
import { ImageView } from '../models/ImageView.model';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ImageService {
|
export class ImageService {
|
||||||
private readonly httpClient = inject(HttpClient);
|
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> {
|
public getImage(backendUrl?: string | null): Promise<string | null> {
|
||||||
if (!!backendUrl) {
|
if (!!backendUrl) {
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
@ -21,4 +27,35 @@ export class ImageService {
|
|||||||
return Promise.resolve(null);
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { ImageDoesNotExistValidator } from './image-does-not-exist-validator';
|
||||||
|
|
||||||
|
describe('ImageUsernameFilenameValidator', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
expect(new ImageDoesNotExistValidator()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/app/shared/validators/image-does-not-exist-validator.ts
Normal file
28
src/app/shared/validators/image-does-not-exist-validator.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user