254 lines
8.7 KiB
TypeScript
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;
|
|
}
|