meals-made-easy-app/src/app/pages/recipe-upload-page/recipe-upload-page.ts
2026-02-18 13:02:50 -06:00

184 lines
7.6 KiB
TypeScript

import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { AiOrManual } from './steps/ai-or-manual/ai-or-manual';
import { AIOrManualSubmitEvent } from './steps/ai-or-manual/AIOrManualSubmitEvent';
import { Infer } from './steps/infer/infer';
import { EnterRecipeData } from './steps/enter-recipe-data/enter-recipe-data';
import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail';
import { ActivatedRoute, Router } from '@angular/router';
import { StepClickEvent } from './recipe-upload-trail/StepClickEvent';
import { RecipeUploadClientModel } from '../../shared/client-models/RecipeUploadClientModel';
import { RecipeDraftService } from '../../shared/services/RecipeDraftService';
import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
import { tryMaybeInt } from '../../shared/util';
import { from, map, switchMap, tap } from 'rxjs';
import { Review } from './steps/review/review';
import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
import { QueryClient } from '@tanstack/angular-query-experimental';
import { ToastrService } from 'ngx-toastr';
@Component({
selector: 'app-recipe-upload-page',
imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail, Review],
templateUrl: './recipe-upload-page.html',
styleUrl: './recipe-upload-page.css',
})
export class RecipeUploadPage implements OnInit {
protected readonly model = signal<RecipeUploadClientModel>({
inProgressStep: RecipeUploadStep.START,
});
protected readonly displayStep = signal<number>(RecipeUploadStep.START);
protected readonly inProgressStep = computed(() => this.model().inProgressStep);
protected readonly includeInfer = signal(false);
protected readonly sourceFile = computed(() => this.model().inputSourceFile ?? null);
private readonly router = inject(Router);
private readonly activatedRoute = inject(ActivatedRoute);
private readonly recipeDraftService = inject(RecipeDraftService);
private readonly queryClient = inject(QueryClient);
private readonly toastrService = inject(ToastrService);
private isValidStep(step: number): boolean {
if (this.model().draft?.lastInference || this.model().draft?.state === 'INFER') {
return step <= RecipeUploadStep.REVIEW;
} else {
return [0, 2, 3].includes(step);
}
}
public ngOnInit(): void {
this.activatedRoute.queryParamMap
.pipe(
map((paramMap) => {
const stepParam: string | null = paramMap.get('step');
const step = tryMaybeInt(stepParam);
return [paramMap.get('draftId'), step] as const;
}),
switchMap(([draftId, step]) => {
if (draftId !== null) {
return this.recipeDraftService.getRecipeUploadClientModel(draftId).pipe(
tap(async (recipeUploadClientModel) => {
await this.switchModel(recipeUploadClientModel);
}),
switchMap((updatedModel) => {
if (step !== null && this.isValidStep(step)) {
return from(this.changeDisplayStep(step));
} else {
return from(this.changeDisplayStep(updatedModel.inProgressStep));
}
}),
);
} else if (step !== null && this.isValidStep(step)) {
return from(this.changeDisplayStep(step));
} else {
return from(this.changeDisplayStep(RecipeUploadStep.START));
}
}),
)
.subscribe();
}
private async switchModel(
model: RecipeUploadClientModel,
switchStep: boolean | RecipeUploadStep = false,
): Promise<void> {
this.model.set(model);
this.includeInfer.set(!!model.draft?.lastInference || model.inProgressStep === RecipeUploadStep.INFER);
if (switchStep === true) {
await this.changeDisplayStep(model.inProgressStep);
} else if (typeof switchStep === 'number') {
await this.changeDisplayStep(switchStep);
}
}
private async changeDisplayStep(targetStep: number): Promise<void> {
this.displayStep.set(targetStep);
await this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {
draftId: this.model().draft?.id,
step: targetStep,
},
queryParamsHandling: 'replace',
});
}
protected async onStepClick(event: StepClickEvent): Promise<void> {
await this.changeDisplayStep(event.step);
}
protected onSourceFileChange(event: FileUploadEvent) {
if (event._tag === 'file-add-event') {
this.model.update((model) => ({
...model,
inputSourceFile: event.file,
}));
} else {
this.model.update((model) => ({
...model,
inputSourceFile: null,
}));
}
}
protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> {
if (event.mode === 'manual') {
const model = await this.recipeDraftService.createManualDraft();
await this.switchModel(model, true);
} else {
await this.switchModel(
{
...this.model(),
inputSourceFile: this.sourceFile(),
inProgressStep: RecipeUploadStep.INFER,
},
true,
);
this.recipeDraftService
.startInference(this.model())
.pipe(
tap((updated) => {
this.switchModel(updated, true);
}),
switchMap((model) => this.recipeDraftService.pollUntilInferenceComplete(model)),
)
.subscribe({
next: (inferredModel) => {
this.switchModel(inferredModel, true);
},
error: (e) => {
console.error(e);
this.toastrService.error('Error while extracting recipe data');
},
});
}
}
protected async onEnterRecipeDataSubmit(event: RecipeEditFormSubmitEvent): Promise<void> {
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
await this.switchModel(model, RecipeUploadStep.REVIEW);
}
protected async onDeleteDraft(): Promise<void> {
await this.recipeDraftService.deleteDraft(this.model().draft!.id);
await this.queryClient.invalidateQueries({
queryKey: ['recipe-upload', 'in-progress-drafts'],
});
await this.switchModel(
{
inProgressStep: RecipeUploadStep.START,
},
RecipeUploadStep.START,
);
}
protected async onPublish(): Promise<void> {
const recipe = await this.recipeDraftService.publish(this.model().draft!.id);
await this.router.navigate(['recipes', recipe.owner.username, recipe.slug]);
}
protected readonly RecipeUploadStep = RecipeUploadStep;
}