meals-made-easy-app/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts
2026-02-10 16:03:57 -06:00

254 lines
8.7 KiB
TypeScript

import {
afterNextRender,
Component,
computed,
ElementRef,
inject,
Injector,
input,
OnInit,
output,
runInInjectionContext,
signal,
viewChild,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { EnterRecipeDataSubmitEvent } from './EnterRecipeDataSubmitEvent';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faBars, 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';
import { IngredientDialog } from './ingredient-dialog/ingredient-dialog';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
} from '@angular/material/table';
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',
imports: [
ReactiveFormsModule,
MatFormField,
MatLabel,
MatInput,
MatButton,
FaIconComponent,
MatMenuTrigger,
MatMenu,
MatMenuItem,
MatTable,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatCell,
MatCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
CdkDropList,
CdkDrag,
DatePipe,
ImageSelect,
MatError,
],
templateUrl: './enter-recipe-data.html',
styleUrl: './enter-recipe-data.css',
})
export class EnterRecipeData implements OnInit {
public readonly draft = input.required<RecipeDraftViewModel>();
public readonly submit = output<EnterRecipeDataSubmitEvent>();
public readonly deleteDraft = output<void>();
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
private readonly dialog = inject(MatDialog);
protected readonly recipeFormGroup = new FormGroup({
title: new FormControl('', Validators.required),
slug: new FormControl('', Validators.required),
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
text: new FormControl('', Validators.required),
});
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
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);
public ngOnInit(): void {
const draft = this.draft();
this.recipeFormGroup.patchValue({
title: draft.title ?? '',
slug: draft.slug ?? '',
preparationTime: draft.preparationTime?.toString() ?? '',
cookingTime: draft.cookingTime?.toString() ?? '',
totalTime: draft.totalTime?.toString() ?? '',
text: draft.rawText ?? '',
});
if (draft.ingredients) {
this.ingredientModels.set(
draft.ingredients.map((ingredient, index) => ({
id: index,
draft: ingredient,
})),
);
}
runInInjectionContext(this.injector, () => {
afterNextRender({
mixedReadWrite: () => {
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
},
});
});
}
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
const windowScrollX = window.scrollX;
const windowScrollY = window.scrollY;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
requestAnimationFrame(() => {
window.scrollTo(windowScrollX, windowScrollY);
});
}
protected onRecipeTextChange(event: Event): void {
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
}
protected addIngredient() {
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
IngredientDialog,
{
data: {
id: this.ingredientModels().length,
draft: {
name: '',
},
},
},
);
dialogRef.afterClosed().subscribe((ingredientModel) => {
if (ingredientModel) {
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
this.ingredientsTable()!.renderRows();
}
});
}
protected editIngredient(model: IngredientDraftClientModel): void {
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
IngredientDialog,
{
data: model,
},
);
dialogRef.afterClosed().subscribe((model) => {
if (model) {
this.ingredientModels.update((models) => {
const updated: IngredientDraftClientModel[] = [...models];
const target = updated.find((search) => search.id === model.id);
if (!target) {
throw new Error('Ingredient does not exist.');
}
target.draft = model.draft;
return updated;
});
}
});
}
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
this.ingredientModels.update((ingredientModels) => {
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
updated.sort((model0, model1) => model0.id - model1.id);
updated.forEach((model, index) => {
model.id = index;
});
return updated;
});
}
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
this.ingredientModels.update((ingredientModels) => {
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
ingredientModels.forEach((model, index) => {
model.id = index;
});
return ingredientModels;
});
this.ingredientsTable()!.renderRows();
}
private getTime(s?: string | null): number | null {
if (!s) return null;
try {
return parseInt(s);
} catch (e) {
console.error(`Should not have had a parse error because of form validators: ${e}`);
return null;
}
}
protected onSubmit(event: SubmitEvent): void {
event.preventDefault();
const value = this.recipeFormGroup.value;
this.submit.emit({
title: value.title!,
slug: value.slug!,
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
preparationTime: this.getTime(value.preparationTime),
cookingTime: this.getTime(value.cookingTime),
totalTime: this.getTime(value.totalTime),
mainImage: this.mainImage(),
rawText: value.text!,
});
}
protected onDraftDelete(): void {
this.deleteDraft.emit();
}
protected openImageUploadDialog(): void {
this.dialog.open(ImageUploadDialog);
}
protected onMainImageSelect(imageView: ImageView | null): void {
this.mainImage.set(imageView);
}
protected readonly faEllipsis = faEllipsis;
protected readonly faBars = faBars;
}