From 68d4409ec6e7ca0d828a34535201ffe8f468ff01 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Wed, 31 Dec 2025 14:00:29 -0600 Subject: [PATCH] Add upload recipe page with AI inference. --- package-lock.json | 14 +++ package.json | 1 + src/app/app.routes.ts | 5 + src/app/nav/nav.html | 1 + src/app/recipe-upload/recipe-upload.css | 51 ++++++++++ src/app/recipe-upload/recipe-upload.html | 48 +++++++++ src/app/recipe-upload/recipe-upload.spec.ts | 22 ++++ src/app/recipe-upload/recipe-upload.ts | 106 ++++++++++++++++++++ src/app/spinner/spinner.css | 19 ++++ src/app/spinner/spinner.html | 3 + src/app/spinner/spinner.spec.ts | 22 ++++ src/app/spinner/spinner.ts | 11 ++ src/styles.css | 2 +- 13 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 src/app/recipe-upload/recipe-upload.css create mode 100644 src/app/recipe-upload/recipe-upload.html create mode 100644 src/app/recipe-upload/recipe-upload.spec.ts create mode 100644 src/app/recipe-upload/recipe-upload.ts create mode 100644 src/app/spinner/spinner.css create mode 100644 src/app/spinner/spinner.html create mode 100644 src/app/spinner/spinner.spec.ts create mode 100644 src/app/spinner/spinner.ts diff --git a/package-lock.json b/package-lock.json index 9607cb0..52ae2c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@tanstack/angular-query-experimental": "^5.90.16", + "ngx-sse-client": "^20.0.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -6736,6 +6737,19 @@ "node": ">= 0.6" } }, + "node_modules/ngx-sse-client": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/ngx-sse-client/-/ngx-sse-client-20.0.1.tgz", + "integrity": "sha512-OSFRirL5beveGj4An3lOzWwg/JZWJG4Q1TdbyW7lqSDacfwINpIjSHdWlpiQwIghKU7BtLAc6TonUGlU4MzGTQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=20.0.0", + "@angular/core": ">=20.0.0" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", diff --git a/package.json b/package.json index 0ea5778..229af2e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@tanstack/angular-query-experimental": "^5.90.16", + "ngx-sse-client": "^20.0.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 324612b..515b187 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { RecipePage } from './recipe-page/recipe-page'; import { RecipesPage } from './recipes-page/recipes-page'; import { RecipesSearchPage } from './recipes-search-page/recipes-search-page'; +import { RecipeUpload } from './recipe-upload/recipe-upload'; export const routes: Routes = [ { @@ -12,6 +13,10 @@ export const routes: Routes = [ path: 'recipes-search', component: RecipesSearchPage, }, + { + path: 'recipes-upload', + component: RecipeUpload, + }, { path: 'recipes/:username/:slug', component: RecipePage, diff --git a/src/app/nav/nav.html b/src/app/nav/nav.html index 11bc2f4..e1e85a8 100644 --- a/src/app/nav/nav.html +++ b/src/app/nav/nav.html @@ -3,5 +3,6 @@ diff --git a/src/app/recipe-upload/recipe-upload.css b/src/app/recipe-upload/recipe-upload.css new file mode 100644 index 0000000..98fb55b --- /dev/null +++ b/src/app/recipe-upload/recipe-upload.css @@ -0,0 +1,51 @@ +#recipe-upload-container { + display: flex; + flex-direction: column; + row-gap: 5px; +} + +form { + display: flex; + width: 100%; + flex-direction: column; + row-gap: 5px; +} + +#recipe-upload-form { + width: 66%; +} + +.action-buttons-container { + width: 100%; + display: flex; + column-gap: 5px; +} + +button { + width: 100%; +} + +input[type='text'], +textarea { + padding: 10px; + font-size: 16px; +} + +textarea { + box-sizing: border-box; + height: auto; + overflow: hidden; + resize: none; +} + +#recipe-form-and-source { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 10px; + justify-items: flex-start; +} + +#recipe-form-and-source div, +#recipe-form-and-source div img { + width: 100%; +} diff --git a/src/app/recipe-upload/recipe-upload.html b/src/app/recipe-upload/recipe-upload.html new file mode 100644 index 0000000..a05862e --- /dev/null +++ b/src/app/recipe-upload/recipe-upload.html @@ -0,0 +1,48 @@ +
+

Upload Recipe

+ +
+

Auto-Complete Recipe (Optional)

+

+ Choose a photo of a recipe from your files, and AI will fill out the form below for you. +

+
+ +
+ + + +
+
+
+ +
+

Recipe Form

+
+
+ + + +
+
+ @if (sourceRecipeImage()) { + Your source recipe image. + } +
+
+
+
diff --git a/src/app/recipe-upload/recipe-upload.spec.ts b/src/app/recipe-upload/recipe-upload.spec.ts new file mode 100644 index 0000000..a8eb3ec --- /dev/null +++ b/src/app/recipe-upload/recipe-upload.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecipeUpload } from './recipe-upload'; + +describe('RecipeUpload', () => { + let component: RecipeUpload; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecipeUpload], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipeUpload); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/recipe-upload/recipe-upload.ts b/src/app/recipe-upload/recipe-upload.ts new file mode 100644 index 0000000..b26e576 --- /dev/null +++ b/src/app/recipe-upload/recipe-upload.ts @@ -0,0 +1,106 @@ +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 '../spinner/spinner'; + +@Component({ + selector: 'app-recipe-upload', + imports: [ReactiveFormsModule, Spinner], + templateUrl: './recipe-upload.html', + styleUrl: './recipe-upload.css', +}) +export class RecipeUpload { + 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'; + } +} diff --git a/src/app/spinner/spinner.css b/src/app/spinner/spinner.css new file mode 100644 index 0000000..c637e7a --- /dev/null +++ b/src/app/spinner/spinner.css @@ -0,0 +1,19 @@ +.loader { + width: 48px; + height: 48px; + border: 5px solid var(--primary-red); + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/app/spinner/spinner.html b/src/app/spinner/spinner.html new file mode 100644 index 0000000..d20c385 --- /dev/null +++ b/src/app/spinner/spinner.html @@ -0,0 +1,3 @@ +@if (enabled()) { + +} diff --git a/src/app/spinner/spinner.spec.ts b/src/app/spinner/spinner.spec.ts new file mode 100644 index 0000000..19d596a --- /dev/null +++ b/src/app/spinner/spinner.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Spinner } from './spinner'; + +describe('Spinner', () => { + let component: Spinner; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Spinner], + }).compileComponents(); + + fixture = TestBed.createComponent(Spinner); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/spinner/spinner.ts b/src/app/spinner/spinner.ts new file mode 100644 index 0000000..ec8e76b --- /dev/null +++ b/src/app/spinner/spinner.ts @@ -0,0 +1,11 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-spinner', + imports: [], + templateUrl: './spinner.html', + styleUrl: './spinner.css', +}) +export class Spinner { + public readonly enabled = input.required(); +} diff --git a/src/styles.css b/src/styles.css index 7a5f50f..07d4a61 100644 --- a/src/styles.css +++ b/src/styles.css @@ -41,7 +41,7 @@ button { padding: 10px; } -button:hover { +button:hover:not(:disabled) { background-color: var(--off-white-1); cursor: pointer; }