diff --git a/src/app/pages/recipe-upload-page/recipe-upload-page.html b/src/app/pages/recipe-upload-page/recipe-upload-page.html index 63a1fc6..86474a9 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.html +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.html @@ -1,6 +1,25 @@

Upload Recipe

+ + @if (displayStep() === RecipeUploadStep.START) { + + } @else if (displayStep() === RecipeUploadStep.INFER) { + + } @else if (displayStep() === RecipeUploadStep.ENTER_DATA) { + + } + +
diff --git a/src/app/pages/recipe-upload-page/recipe-upload-page.ts b/src/app/pages/recipe-upload-page/recipe-upload-page.ts index 481c605..d828963 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.ts +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.ts @@ -1,100 +1,212 @@ -import { Component, inject, signal } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { SseClient } from 'ngx-sse-client'; -import { Spinner } from '../../shared/components/spinner/spinner'; -import { MatButton } from '@angular/material/button'; -import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; +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 { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel'; +import { RecipeUploadService } from '../../shared/services/RecipeUploadService'; +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'; @Component({ selector: 'app-recipe-upload-page', - imports: [ReactiveFormsModule, Spinner, MatButton, MatFormField, MatInput, MatLabel], + imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail], templateUrl: './recipe-upload-page.html', styleUrl: './recipe-upload-page.css', }) -export class RecipeUploadPage { - private readonly sseClient = inject(SseClient); - private readonly formBuilder = inject(FormBuilder); - - protected readonly sourceRecipeImage = signal(null); - protected readonly inferenceInProgress = signal(false); - - protected readonly recipeUploadForm = this.formBuilder.group({ - file: this.formBuilder.control(null, [Validators.required]), +export class RecipeUploadPage implements OnInit { + protected readonly model = signal({ + inProgressStep: RecipeUploadStep.START, }); - protected readonly recipeForm = new FormGroup({ - title: new FormControl('', [Validators.required]), - recipeText: new FormControl('', Validators.required), - }); + protected readonly displayStep = signal(RecipeUploadStep.START); + protected readonly inProgressStep = computed(() => this.model().inProgressStep); + protected readonly includeInfer = signal(false); + protected readonly sourceFile = computed(() => this.model().sourceFile ?? null); - protected onClear() { - this.recipeUploadForm.reset(); - this.sourceRecipeImage.set(null); + private readonly router = inject(Router); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly recipeUploadService = inject(RecipeUploadService); + + public ngOnInit(): void { + this.activatedRoute.queryParamMap + .pipe( + map((paramMap) => { + const draftIdParam: string | null = paramMap.get('draftId'); + const draftId = tryMaybeInt(draftIdParam); + const stepParam: string | null = paramMap.get('step'); + const step = tryMaybeInt(stepParam); + return [draftId, step]; + }), + switchMap(([draftId, step]) => { + const currentModel = this.model(); + if (draftId !== null && currentModel.id !== draftId) { + return this.recipeUploadService.getRecipeUploadModel(draftId).pipe( + tap((updatedModel) => { + this.model.set(updatedModel); + }), + switchMap((updatedModel) => { + if (step !== null && step <= updatedModel.inProgressStep) { + return from(this.changeDisplayStep(step)); + } else { + return from(this.changeDisplayStep(updatedModel.inProgressStep)); + } + }), + ); + } else if (step !== null && step <= currentModel.inProgressStep) { + return from(this.changeDisplayStep(step)); + } else { + return from(this.changeDisplayStep(RecipeUploadStep.START)); + } + }), + ) + .subscribe(); } - protected onFileChange(event: Event) { - const fileInput = event.target as HTMLInputElement; - if (fileInput.files && fileInput.files.length) { - const file = fileInput.files[0]; - this.recipeUploadForm.controls.file.setValue(file); - this.recipeUploadForm.controls.file.markAsTouched(); - this.recipeUploadForm.controls.file.updateValueAndValidity(); + private async changeDisplayStep(targetStep: number): Promise { + this.displayStep.set(targetStep); + await this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { + step: targetStep, + draftId: this.model().id, + }, + queryParamsHandling: 'merge', + }); + } - // set source image - this.sourceRecipeImage.set(URL.createObjectURL(file)); + protected async onStepClick(event: StepClickEvent): Promise { + await this.changeDisplayStep(event.step); + } + + protected onSourceFileChange(event: FileUploadEvent) { + if (event._tag === 'file-add-event') { + this.model.update((model) => ({ + ...model, + sourceFile: event.file, + })); + } else { + this.model.update((model) => ({ + ...model, + sourceFile: null, + })); } } - protected onFileSubmit() { - const rawValue = this.recipeUploadForm.getRawValue(); - - this.inferenceInProgress.set(true); - - // upload form data - const formData = new FormData(); - formData.append('recipeImageFile', rawValue.file!, rawValue.file!.name); - this.sseClient - .stream( - `http://localhost:8080/inferences/recipe-extract-stream`, - { - keepAlive: false, - reconnectionDelay: 1000, - responseType: 'event', - }, - { - body: formData, - }, - 'PUT', - ) - .subscribe({ - next: (event) => { - if (event.type === 'error') { - const errorEvent = event as ErrorEvent; - console.error(errorEvent.error, errorEvent.message); - } else { - const messageEvent = event as MessageEvent; - const data: { delta: string } = JSON.parse(messageEvent.data); - this.recipeForm.patchValue({ - recipeText: this.recipeForm.value.recipeText + data.delta, - }); - - // must do this so we auto-resize the textarea - document.getElementById('recipe-text')?.dispatchEvent(new Event('input', { bubbles: true })); - } - }, - complete: () => { - this.inferenceInProgress.set(false); - }, + protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise { + if (event.mode === 'manual') { + this.model.update((model) => ({ + ...model, + sourceFile: null, + inProgressStep: RecipeUploadStep.ENTER_DATA, + })); + await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA); + this.includeInfer.set(false); + } else { + this.model.update((model) => ({ + ...model, + sourceFile: this.sourceFile(), + inProgressStep: RecipeUploadStep.INFER, + })); + await this.changeDisplayStep(RecipeUploadStep.INFER); + this.includeInfer.set(true); + this.recipeUploadService.doInference(this.model()).subscribe((updatedModel) => { + this.model.set(updatedModel); + this.changeDisplayStep(RecipeUploadStep.ENTER_DATA); }); + } } - protected onRecipeSubmit() { - console.log(this.recipeForm.value); - } - - protected onRecipeTextChange(event: Event) { - const textarea = event.target as HTMLTextAreaElement; - textarea.style.height = 'auto'; - textarea.style.height = textarea.scrollHeight + 'px'; - } + // private readonly sseClient = inject(SseClient); + // private readonly formBuilder = inject(FormBuilder); + // + // protected readonly sourceRecipeImage = signal(null); + // protected readonly inferenceInProgress = signal(false); + // + // protected readonly recipeUploadForm = this.formBuilder.group({ + // file: this.formBuilder.control(null, [Validators.required]), + // }); + // + // protected readonly recipeForm = new FormGroup({ + // title: new FormControl('', [Validators.required]), + // recipeText: new FormControl('', Validators.required), + // }); + // + // protected onClear() { + // this.recipeUploadForm.reset(); + // this.sourceRecipeImage.set(null); + // } + // + // protected onFileChange(event: Event) { + // const fileInput = event.target as HTMLInputElement; + // if (fileInput.files && fileInput.files.length) { + // const file = fileInput.files[0]; + // this.recipeUploadForm.controls.file.setValue(file); + // this.recipeUploadForm.controls.file.markAsTouched(); + // this.recipeUploadForm.controls.file.updateValueAndValidity(); + // + // // set source image + // this.sourceRecipeImage.set(URL.createObjectURL(file)); + // } + // } + // + // protected onFileSubmit() { + // const rawValue = this.recipeUploadForm.getRawValue(); + // + // this.inferenceInProgress.set(true); + // + // // upload form data + // const formData = new FormData(); + // formData.append('recipeImageFile', rawValue.file!, rawValue.file!.name); + // this.sseClient + // .stream( + // `http://localhost:8080/inferences/recipe-extract-stream`, + // { + // keepAlive: false, + // reconnectionDelay: 1000, + // responseType: 'event', + // }, + // { + // body: formData, + // }, + // 'PUT', + // ) + // .subscribe({ + // next: (event) => { + // if (event.type === 'error') { + // const errorEvent = event as ErrorEvent; + // console.error(errorEvent.error, errorEvent.message); + // } else { + // const messageEvent = event as MessageEvent; + // const data: { delta: string } = JSON.parse(messageEvent.data); + // this.recipeForm.patchValue({ + // recipeText: this.recipeForm.value.recipeText + data.delta, + // }); + // + // // must do this so we auto-resize the textarea + // document.getElementById('recipe-text')?.dispatchEvent(new Event('input', { bubbles: true })); + // } + // }, + // complete: () => { + // this.inferenceInProgress.set(false); + // }, + // }); + // } + // + // protected onRecipeSubmit() { + // console.log(this.recipeForm.value); + // } + // + // protected onRecipeTextChange(event: Event) { + // const textarea = event.target as HTMLTextAreaElement; + // textarea.style.height = 'auto'; + // textarea.style.height = textarea.scrollHeight + 'px'; + // } + protected readonly RecipeUploadStep = RecipeUploadStep; } diff --git a/src/app/pages/recipe-upload-page/recipe-upload-trail/StepClickEvent.ts b/src/app/pages/recipe-upload-page/recipe-upload-trail/StepClickEvent.ts new file mode 100644 index 0000000..f5a24ae --- /dev/null +++ b/src/app/pages/recipe-upload-page/recipe-upload-trail/StepClickEvent.ts @@ -0,0 +1,3 @@ +export interface StepClickEvent { + step: number; +} diff --git a/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.css b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.css new file mode 100644 index 0000000..95171c1 --- /dev/null +++ b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.css @@ -0,0 +1,32 @@ +#recipe-upload-steps { + display: flex; + list-style-type: none; + margin-block: 0; + padding-inline: 0; +} + +#recipe-upload-steps li { + display: inline; +} + +#recipe-upload-steps li:not(:first-child)::before { + content: ">"; + padding-inline: 5px; +} + +.step-complete, +.step-in-progress { + cursor: pointer; +} + +.step-in-progress { + opacity: 0.9; +} + +.step-incomplete { + opacity: 0.6; +} + +.step-displayed { + font-weight: 700; +} diff --git a/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.html b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.html new file mode 100644 index 0000000..12f9e4c --- /dev/null +++ b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.html @@ -0,0 +1,18 @@ +
    + @for (step of steps(); track step.index) { +
  • + @if (step.completed || step.inProgress) { + {{ step.name }} + } @else { + {{ step.name }} + } +
  • + } +
diff --git a/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.spec.ts b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.spec.ts new file mode 100644 index 0000000..e861bcb --- /dev/null +++ b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecipeUploadTrail } from './recipe-upload-trail'; + +describe('RecipeUploadTrail', () => { + let component: RecipeUploadTrail; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecipeUploadTrail], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipeUploadTrail); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.ts b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.ts new file mode 100644 index 0000000..2b61fa7 --- /dev/null +++ b/src/app/pages/recipe-upload-page/recipe-upload-trail/recipe-upload-trail.ts @@ -0,0 +1,53 @@ +import { Component, computed, input, output } from '@angular/core'; +import { StepClickEvent } from './StepClickEvent'; +import { RecipeUploadStep } from '../../../shared/client-models/RecipeUploadStep'; + +@Component({ + selector: 'app-recipe-upload-trail', + imports: [], + templateUrl: './recipe-upload-trail.html', + styleUrl: './recipe-upload-trail.css', +}) +export class RecipeUploadTrail { + public readonly displayStep = input.required(); + public readonly inProgressStep = input.required(); + public readonly includeInfer = input.required(); + + public readonly stepClick = output(); + + protected readonly steps = computed(() => { + const base: { + index: RecipeUploadStep; + name: string; + completed: boolean; + inProgress: boolean; + }[] = [ + { + index: RecipeUploadStep.START, + name: 'Start', + completed: this.inProgressStep() > RecipeUploadStep.START, + inProgress: this.inProgressStep() === RecipeUploadStep.START, + }, + { + index: RecipeUploadStep.ENTER_DATA, + name: 'Enter Recipe', + completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA, + inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA, + }, + ]; + if (this.includeInfer()) { + base.push({ + index: RecipeUploadStep.INFER, + name: 'Infer', + completed: this.inProgressStep() > RecipeUploadStep.INFER, + inProgress: this.inProgressStep() === RecipeUploadStep.INFER, + }); + } + base.sort((a, b) => a.index - b.index); + return base; + }); + + protected onStepClick(stepIndex: number) { + this.stepClick.emit({ step: stepIndex }); + } +} diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/AIOrManualSubmitEvent.ts b/src/app/pages/recipe-upload-page/steps/ai-or-manual/AIOrManualSubmitEvent.ts new file mode 100644 index 0000000..32270e2 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/ai-or-manual/AIOrManualSubmitEvent.ts @@ -0,0 +1,3 @@ +export interface AIOrManualSubmitEvent { + mode: 'manual' | 'ai-assist'; +} diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.css b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html new file mode 100644 index 0000000..3bff67e --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html @@ -0,0 +1,11 @@ +
+

Start

+

Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.

+
+ +
+ + +
+
+
diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.spec.ts b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.spec.ts new file mode 100644 index 0000000..d56cc90 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AiOrManual } from './ai-or-manual'; + +describe('AiOrManual', () => { + let component: AiOrManual; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AiOrManual], + }).compileComponents(); + + fixture = TestBed.createComponent(AiOrManual); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts new file mode 100644 index 0000000..62c43b9 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts @@ -0,0 +1,35 @@ +import { Component, computed, input, output } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { ReactiveFormsModule } from '@angular/forms'; +import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent'; +import { FileUpload } from '../../../../shared/components/file-upload/file-upload'; +import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent'; + +@Component({ + selector: 'app-ai-or-manual', + imports: [MatButton, ReactiveFormsModule, FileUpload], + templateUrl: './ai-or-manual.html', + styleUrl: './ai-or-manual.css', +}) +export class AiOrManual { + public sourceFile = input.required(); + public sourceFileChange = output(); + public submitStep = output(); + + protected readonly sourceFilesArray = computed(() => { + const maybeSourceFile = this.sourceFile(); + if (maybeSourceFile) { + return [maybeSourceFile]; + } else { + return []; + } + }); + + protected onFileChange(event: FileUploadEvent) { + this.sourceFileChange.emit(event); + } + + protected onFormSubmit(mode: 'manual' | 'ai-assist') { + this.submitStep.emit({ mode }); + } +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css new file mode 100644 index 0000000..12113fc --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css @@ -0,0 +1,5 @@ +form { + display: flex; + flex-direction: column; + width: 60ch; +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html new file mode 100644 index 0000000..fe876e8 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html @@ -0,0 +1,15 @@ +

Enter Recipe

+
+ + Title + + + + Slug + + + + Recipe Text + + +
diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts new file mode 100644 index 0000000..6738e68 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EnterRecipeData } from './enter-recipe-data'; + +describe('EnterRecipeData', () => { + let component: EnterRecipeData; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EnterRecipeData], + }).compileComponents(); + + fixture = TestBed.createComponent(EnterRecipeData); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts new file mode 100644 index 0000000..d9e1e02 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts @@ -0,0 +1,29 @@ +import { Component, input, OnInit } from '@angular/core'; +import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; + +@Component({ + selector: 'app-enter-recipe-data', + imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput], + templateUrl: './enter-recipe-data.html', + styleUrl: './enter-recipe-data.css', +}) +export class EnterRecipeData implements OnInit { + public readonly model = input.required(); + + public ngOnInit(): void { + const model = this.model(); + this.recipeFormGroup.patchValue({ + title: model.userTitle ?? model.inferredTitle ?? '', + slug: model.userSlug ?? model.inferredSlug ?? '', + text: model.userText ?? model.inferredText ?? '', + }); + } + + protected readonly recipeFormGroup = new FormGroup({ + title: new FormControl('', Validators.required), + slug: new FormControl('', Validators.required), + text: new FormControl('', Validators.required), + }); +} diff --git a/src/app/pages/recipe-upload-page/steps/infer/infer.css b/src/app/pages/recipe-upload-page/steps/infer/infer.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/recipe-upload-page/steps/infer/infer.html b/src/app/pages/recipe-upload-page/steps/infer/infer.html new file mode 100644 index 0000000..57744a7 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/infer/infer.html @@ -0,0 +1,2 @@ +

Using AI to read your recipe...

+ diff --git a/src/app/pages/recipe-upload-page/steps/infer/infer.spec.ts b/src/app/pages/recipe-upload-page/steps/infer/infer.spec.ts new file mode 100644 index 0000000..8a24ba2 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/infer/infer.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Infer } from './infer'; + +describe('Infer', () => { + let component: Infer; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Infer], + }).compileComponents(); + + fixture = TestBed.createComponent(Infer); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-upload-page/steps/infer/infer.ts b/src/app/pages/recipe-upload-page/steps/infer/infer.ts new file mode 100644 index 0000000..62fa7ca --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/infer/infer.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { Spinner } from '../../../../shared/components/spinner/spinner'; + +@Component({ + selector: 'app-infer', + imports: [Spinner], + templateUrl: './infer.html', + styleUrl: './infer.css', +}) +export class Infer {} diff --git a/src/app/shared/client-models/RecipeUploadIngredientModel.ts b/src/app/shared/client-models/RecipeUploadIngredientModel.ts new file mode 100644 index 0000000..f979e12 --- /dev/null +++ b/src/app/shared/client-models/RecipeUploadIngredientModel.ts @@ -0,0 +1,5 @@ +export interface RecipeUploadIngredientModel { + amount: string | null; + name: string; + notes: string | null; +} diff --git a/src/app/shared/client-models/RecipeUploadModel.ts b/src/app/shared/client-models/RecipeUploadModel.ts new file mode 100644 index 0000000..010dc89 --- /dev/null +++ b/src/app/shared/client-models/RecipeUploadModel.ts @@ -0,0 +1,19 @@ +import { RecipeUploadIngredientModel } from './RecipeUploadIngredientModel'; +import { RecipeUploadStep } from './RecipeUploadStep'; + +export interface RecipeUploadModel { + inProgressStep: RecipeUploadStep; + + id?: number | null; + sourceFile?: File | null; + + inferredText?: string | null; + inferredIngredients?: RecipeUploadIngredientModel[] | null; + inferredTitle?: string | null; + inferredSlug?: string | null; + + userText?: string | null; + userIngredients?: RecipeUploadIngredientModel[] | null; + userTitle?: string | null; + userSlug?: string | null; +} diff --git a/src/app/shared/client-models/RecipeUploadStep.ts b/src/app/shared/client-models/RecipeUploadStep.ts new file mode 100644 index 0000000..ab694a8 --- /dev/null +++ b/src/app/shared/client-models/RecipeUploadStep.ts @@ -0,0 +1,5 @@ +export enum RecipeUploadStep { + START, + INFER, + ENTER_DATA, +} diff --git a/src/app/shared/components/file-upload/FileUploadEvent.ts b/src/app/shared/components/file-upload/FileUploadEvent.ts new file mode 100644 index 0000000..6c0786c --- /dev/null +++ b/src/app/shared/components/file-upload/FileUploadEvent.ts @@ -0,0 +1,11 @@ +export type FileUploadEvent = FileAddEvent | FileRemoveEvent; + +export interface FileAddEvent { + _tag: 'file-add-event'; + file: File; +} + +export interface FileRemoveEvent { + _tag: 'file-remove-event'; + fileName: string; +} diff --git a/src/app/shared/components/file-upload/file-upload.css b/src/app/shared/components/file-upload/file-upload.css new file mode 100644 index 0000000..8d6a06e --- /dev/null +++ b/src/app/shared/components/file-upload/file-upload.css @@ -0,0 +1,14 @@ +.file-input-container { + display: flex; + column-gap: 10px; + padding-block: 10px; +} + +.file-name { + display: flex; + column-gap: 5px; +} + +fa-icon { + cursor: pointer; +} diff --git a/src/app/shared/components/file-upload/file-upload.html b/src/app/shared/components/file-upload/file-upload.html new file mode 100644 index 0000000..c2a2b96 --- /dev/null +++ b/src/app/shared/components/file-upload/file-upload.html @@ -0,0 +1,11 @@ + +
+ + @if (fileNames().length) { + @for (fileName of fileNames(); track $index) { +

{{ fileName }}

+ } + } @else { +

Click the icon to choose a file.

+ } +
diff --git a/src/app/shared/components/file-upload/file-upload.spec.ts b/src/app/shared/components/file-upload/file-upload.spec.ts new file mode 100644 index 0000000..3e7998d --- /dev/null +++ b/src/app/shared/components/file-upload/file-upload.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileUpload } from './file-upload'; + +describe('FileUpload', () => { + let component: FileUpload; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FileUpload], + }).compileComponents(); + + fixture = TestBed.createComponent(FileUpload); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/file-upload/file-upload.ts b/src/app/shared/components/file-upload/file-upload.ts new file mode 100644 index 0000000..03815c7 --- /dev/null +++ b/src/app/shared/components/file-upload/file-upload.ts @@ -0,0 +1,42 @@ +import { Component, computed, input, output } from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faCancel, faFileUpload } from '@fortawesome/free-solid-svg-icons'; +import { FileUploadEvent } from './FileUploadEvent'; + +@Component({ + selector: 'app-file-upload', + imports: [FaIconComponent], + templateUrl: './file-upload.html', + styleUrl: './file-upload.css', +}) +export class FileUpload { + public readonly files = input([]); + public readonly fileChange = output(); + + protected fileNames = computed(() => this.files().map((file) => file.name)); + + protected onFileUploadIconClick(target: HTMLInputElement) { + target.click(); + } + + protected onClear(fileName: string): void { + this.fileChange.emit({ + _tag: 'file-remove-event', + fileName, + }); + } + + protected onFileChange(event: Event) { + const fileInput = event.target as HTMLInputElement; + if (fileInput.files && fileInput.files.length) { + this.fileChange.emit({ + _tag: 'file-add-event', + file: fileInput.files[0], + }); + } + fileInput.value = ''; + } + + protected readonly faFileUpload = faFileUpload; + protected readonly faCancel = faCancel; +} diff --git a/src/app/shared/components/spinner/spinner.ts b/src/app/shared/components/spinner/spinner.ts index ec8e76b..0ebdc0c 100644 --- a/src/app/shared/components/spinner/spinner.ts +++ b/src/app/shared/components/spinner/spinner.ts @@ -7,5 +7,5 @@ import { Component, input } from '@angular/core'; styleUrl: './spinner.css', }) export class Spinner { - public readonly enabled = input.required(); + public readonly enabled = input(true); } diff --git a/src/app/shared/services/RecipeUploadService.ts b/src/app/shared/services/RecipeUploadService.ts new file mode 100644 index 0000000..29411de --- /dev/null +++ b/src/app/shared/services/RecipeUploadService.ts @@ -0,0 +1,31 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { delay, Observable, of } from 'rxjs'; +import { RecipeUploadModel } from '../client-models/RecipeUploadModel'; +import { RecipeUploadStep } from '../client-models/RecipeUploadStep'; + +@Injectable({ + providedIn: 'root', +}) +export class RecipeUploadService { + private readonly http = inject(HttpClient); + + public getRecipeUploadModel(draftId: number): Observable { + return of({ + inProgressStep: RecipeUploadStep.ENTER_DATA, + id: 42 + }); + } + + public doInference(model: RecipeUploadModel): Observable { + return of({ + inProgressStep: RecipeUploadStep.ENTER_DATA, + id: 16, + inferredTitle: 'Some recipe', + inferredSlug: 'some-recipe', + inferredText: 'Some text.', + inferredIngredients: [] + }).pipe(delay(5_000)); + } + +} diff --git a/src/app/shared/util.ts b/src/app/shared/util.ts new file mode 100644 index 0000000..d035de7 --- /dev/null +++ b/src/app/shared/util.ts @@ -0,0 +1,23 @@ +export const tryInt = (s: string): number | null => { + try { + return parseInt(s); + } catch (e) { + console.error(e); + return null; + } +}; + +export const tryMaybeInt = (maybeString: string | null): number | null => { + if (maybeString) { + try { + return parseInt(maybeString); + } catch (e) { + console.error(e); + } + } + return null; +}; + +export const hasValue = (value?: T | null): value is T => { + return value !== undefined && value !== null; +}; diff --git a/src/material-theme.scss b/src/material-theme.scss index 0b32586..b21bd41 100644 --- a/src/material-theme.scss +++ b/src/material-theme.scss @@ -20,9 +20,11 @@ html { ); } -@include mat.form-field-overrides(( - filled-container-color: var(--mat-sys-surface-variant), -)); +@include mat.form-field-overrides( + ( + filled-container-color: var(--mat-sys-surface-variant), + ) +); body { // Default the application to a light color theme. This can be changed to diff --git a/src/theme-colors.scss b/src/theme-colors.scss index b24f33b..24fa28e 100644 --- a/src/theme-colors.scss +++ b/src/theme-colors.scss @@ -1,136 +1,136 @@ // This file was generated by running 'ng generate @angular/material:theme-color'. // Proceed with caution if making changes to this file. -@use 'sass:map'; -@use '@angular/material' as mat; +@use "sass:map"; +@use "@angular/material" as mat; // Note: Color palettes are generated from primary: #91351d, secondary: #ffb61d, neutral: #aaa, neutral variant: #252525 $_palettes: ( - primary: ( - 0: #000000, - 10: #3c0800, - 20: #611200, - 25: #711e07, - 30: #802912, - 35: #90341c, - 40: #a04027, - 50: #c0573c, - 60: #e07053, - 70: #ff8b6d, - 80: #ffb4a2, - 90: #ffdbd2, - 95: #ffede9, - 98: #fff8f6, - 99: #fffbff, - 100: #ffffff, - ), - secondary: ( - 0: #000000, - 10: #281900, - 20: #432c00, - 25: #513600, - 30: #5f4100, - 35: #6f4c00, - 40: #7e5700, - 50: #9e6e00, - 60: #bf8600, - 70: #e29f00, - 80: #ffba36, - 90: #ffdeac, - 95: #ffeed9, - 98: #fff8f3, - 99: #fffbff, - 100: #ffffff, - ), - tertiary: ( - 0: #000000, - 10: #241a00, - 20: #3d2f00, - 25: #4a3900, - 30: #584400, - 35: #665000, - 40: #735b0d, - 50: #8e7426, - 60: #aa8e3e, - 70: #c6a855, - 80: #e3c36d, - 90: #ffe08e, - 95: #ffefce, - 98: #fff8f1, - 99: #fffbff, - 100: #ffffff, - ), - neutral: ( - 0: #000000, - 10: #1a1c1c, - 20: #2f3131, - 25: #3a3c3c, - 30: #464747, - 35: #525253, - 40: #5e5e5f, - 50: #767777, - 60: #909191, - 70: #ababab, - 80: #c7c6c6, - 90: #e3e2e2, - 95: #f1f0f0, - 98: #faf9f9, - 99: #fdfcfc, - 100: #ffffff, - 4: #0d0e0f, - 6: #121414, - 12: #1e2020, - 17: #292a2a, - 22: #343535, - 24: #38393a, - 87: #dadada, - 92: #e9e8e8, - 94: #eeeeed, - 96: #f4f3f3, - ), - neutral-variant: ( - 0: #000000, - 10: #1b1c1c, - 20: #303030, - 25: #3c3b3b, - 30: #474746, - 35: #535252, - 40: #5f5e5e, - 50: #787776, - 60: #929090, - 70: #adabaa, - 80: #c8c6c5, - 90: #e4e2e1, - 95: #f3f0ef, - 98: #fcf9f8, - 99: #fffbfb, - 100: #ffffff, - ), - error: ( - 0: #000000, - 10: #410002, - 20: #690005, - 25: #7e0007, - 30: #93000a, - 35: #a80710, - 40: #ba1a1a, - 50: #de3730, - 60: #ff5449, - 70: #ff897d, - 80: #ffb4ab, - 90: #ffdad6, - 95: #ffedea, - 98: #fff8f7, - 99: #fffbff, - 100: #ffffff, - ), + primary: ( + 0: #000000, + 10: #3c0800, + 20: #611200, + 25: #711e07, + 30: #802912, + 35: #90341c, + 40: #a04027, + 50: #c0573c, + 60: #e07053, + 70: #ff8b6d, + 80: #ffb4a2, + 90: #ffdbd2, + 95: #ffede9, + 98: #fff8f6, + 99: #fffbff, + 100: #ffffff, + ), + secondary: ( + 0: #000000, + 10: #281900, + 20: #432c00, + 25: #513600, + 30: #5f4100, + 35: #6f4c00, + 40: #7e5700, + 50: #9e6e00, + 60: #bf8600, + 70: #e29f00, + 80: #ffba36, + 90: #ffdeac, + 95: #ffeed9, + 98: #fff8f3, + 99: #fffbff, + 100: #ffffff, + ), + tertiary: ( + 0: #000000, + 10: #241a00, + 20: #3d2f00, + 25: #4a3900, + 30: #584400, + 35: #665000, + 40: #735b0d, + 50: #8e7426, + 60: #aa8e3e, + 70: #c6a855, + 80: #e3c36d, + 90: #ffe08e, + 95: #ffefce, + 98: #fff8f1, + 99: #fffbff, + 100: #ffffff, + ), + neutral: ( + 0: #000000, + 10: #1a1c1c, + 20: #2f3131, + 25: #3a3c3c, + 30: #464747, + 35: #525253, + 40: #5e5e5f, + 50: #767777, + 60: #909191, + 70: #ababab, + 80: #c7c6c6, + 90: #e3e2e2, + 95: #f1f0f0, + 98: #faf9f9, + 99: #fdfcfc, + 100: #ffffff, + 4: #0d0e0f, + 6: #121414, + 12: #1e2020, + 17: #292a2a, + 22: #343535, + 24: #38393a, + 87: #dadada, + 92: #e9e8e8, + 94: #eeeeed, + 96: #f4f3f3, + ), + neutral-variant: ( + 0: #000000, + 10: #1b1c1c, + 20: #303030, + 25: #3c3b3b, + 30: #474746, + 35: #535252, + 40: #5f5e5e, + 50: #787776, + 60: #929090, + 70: #adabaa, + 80: #c8c6c5, + 90: #e4e2e1, + 95: #f3f0ef, + 98: #fcf9f8, + 99: #fffbfb, + 100: #ffffff, + ), + error: ( + 0: #000000, + 10: #410002, + 20: #690005, + 25: #7e0007, + 30: #93000a, + 35: #a80710, + 40: #ba1a1a, + 50: #de3730, + 60: #ff5449, + 70: #ff897d, + 80: #ffb4ab, + 90: #ffdad6, + 95: #ffedea, + 98: #fff8f7, + 99: #fffbff, + 100: #ffffff, + ), ); $_rest: ( - secondary: map.get($_palettes, secondary), - neutral: map.get($_palettes, neutral), - neutral-variant: map.get($_palettes, neutral-variant), - error: map.get($_palettes, error), + secondary: map.get($_palettes, secondary), + neutral: map.get($_palettes, neutral), + neutral-variant: map.get($_palettes, neutral-variant), + error: map.get($_palettes, error), ); $primary-palette: map.merge(map.get($_palettes, primary), $_rest);