Add upload recipe page with AI inference.

This commit is contained in:
Jesse Brault 2025-12-31 14:00:29 -06:00
parent df3253ade1
commit 68d4409ec6
13 changed files with 304 additions and 1 deletions

14
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tanstack/angular-query-experimental": "^5.90.16", "@tanstack/angular-query-experimental": "^5.90.16",
"ngx-sse-client": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@ -6736,6 +6737,19 @@
"node": ">= 0.6" "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": { "node_modules/node-addon-api": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

View File

@ -32,6 +32,7 @@
"@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tanstack/angular-query-experimental": "^5.90.16", "@tanstack/angular-query-experimental": "^5.90.16",
"ngx-sse-client": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },

View File

@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
import { RecipePage } from './recipe-page/recipe-page'; import { RecipePage } from './recipe-page/recipe-page';
import { RecipesPage } from './recipes-page/recipes-page'; import { RecipesPage } from './recipes-page/recipes-page';
import { RecipesSearchPage } from './recipes-search-page/recipes-search-page'; import { RecipesSearchPage } from './recipes-search-page/recipes-search-page';
import { RecipeUpload } from './recipe-upload/recipe-upload';
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -12,6 +13,10 @@ export const routes: Routes = [
path: 'recipes-search', path: 'recipes-search',
component: RecipesSearchPage, component: RecipesSearchPage,
}, },
{
path: 'recipes-upload',
component: RecipeUpload,
},
{ {
path: 'recipes/:username/:slug', path: 'recipes/:username/:slug',
component: RecipePage, component: RecipePage,

View File

@ -3,5 +3,6 @@
<ul> <ul>
<li><a [routerLink]="'/'">Browse Recipes</a></li> <li><a [routerLink]="'/'">Browse Recipes</a></li>
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li> <li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
<li><a [routerLink]="'/recipes-upload'">Upload Recipe</a></li>
</ul> </ul>
</nav> </nav>

View File

@ -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%;
}

View File

@ -0,0 +1,48 @@
<div id="recipe-upload-container">
<h1>Upload Recipe</h1>
<section>
<h2>Auto-Complete Recipe (Optional)</h2>
<p>
Choose a photo of a recipe from your files, and AI will fill out the form below for you.
</p>
<form id="recipe-upload-form" [formGroup]="recipeUploadForm" (ngSubmit)="onFileSubmit()">
<input id="file" type="file" (change)="onFileChange($event)" />
<div class="action-buttons-container">
<button type="button" (click)="onClear()">Clear</button>
<button type="submit" [disabled]="!recipeUploadForm.valid || inferenceInProgress()">
AI Auto-Complete
</button>
<app-spinner [enabled]="inferenceInProgress()"></app-spinner>
</div>
</form>
</section>
<section>
<h2>Recipe Form</h2>
<div id="recipe-form-and-source">
<form [formGroup]="recipeForm" (ngSubmit)="onRecipeSubmit()">
<input
id="recipe-title"
type="text"
formControlName="title"
placeholder="Recipe Title"
/>
<textarea
id="recipe-text"
formControlName="recipeText"
(input)="onRecipeTextChange($event)"
placeholder="Recipe text"
></textarea>
<button type="submit" [disabled]="!recipeForm.valid || inferenceInProgress()">
Add Recipe
</button>
</form>
<div>
@if (sourceRecipeImage()) {
<img [src]="sourceRecipeImage()" alt="Your source recipe image." />
}
</div>
</div>
</section>
</div>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RecipeUpload } from './recipe-upload';
describe('RecipeUpload', () => {
let component: RecipeUpload;
let fixture: ComponentFixture<RecipeUpload>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RecipeUpload],
}).compileComponents();
fixture = TestBed.createComponent(RecipeUpload);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<string | null>(null);
protected readonly inferenceInProgress = signal(false);
protected readonly recipeUploadForm = this.formBuilder.group({
file: this.formBuilder.control<File | null>(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';
}
}

View File

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

View File

@ -0,0 +1,3 @@
@if (enabled()) {
<span class="loader"></span>
}

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Spinner } from './spinner';
describe('Spinner', () => {
let component: Spinner;
let fixture: ComponentFixture<Spinner>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Spinner],
}).compileComponents();
fixture = TestBed.createComponent(Spinner);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean>();
}

View File

@ -41,7 +41,7 @@ button {
padding: 10px; padding: 10px;
} }
button:hover { button:hover:not(:disabled) {
background-color: var(--off-white-1); background-color: var(--off-white-1);
cursor: pointer; cursor: pointer;
} }