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
+
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 @@
+
+
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);