Add upload recipe page with AI inference.
This commit is contained in:
parent
df3253ade1
commit
68d4409ec6
14
package-lock.json
generated
14
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
51
src/app/recipe-upload/recipe-upload.css
Normal file
51
src/app/recipe-upload/recipe-upload.css
Normal 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%;
|
||||||
|
}
|
||||||
48
src/app/recipe-upload/recipe-upload.html
Normal file
48
src/app/recipe-upload/recipe-upload.html
Normal 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>
|
||||||
22
src/app/recipe-upload/recipe-upload.spec.ts
Normal file
22
src/app/recipe-upload/recipe-upload.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/app/recipe-upload/recipe-upload.ts
Normal file
106
src/app/recipe-upload/recipe-upload.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/spinner/spinner.css
Normal file
19
src/app/spinner/spinner.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/app/spinner/spinner.html
Normal file
3
src/app/spinner/spinner.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@if (enabled()) {
|
||||||
|
<span class="loader"></span>
|
||||||
|
}
|
||||||
22
src/app/spinner/spinner.spec.ts
Normal file
22
src/app/spinner/spinner.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/app/spinner/spinner.ts
Normal file
11
src/app/spinner/spinner.ts
Normal 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>();
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user