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/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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -3,5 +3,6 @@
|
||||
<ul>
|
||||
<li><a [routerLink]="'/'">Browse Recipes</a></li>
|
||||
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
|
||||
<li><a [routerLink]="'/recipes-upload'">Upload Recipe</a></li>
|
||||
</ul>
|
||||
</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;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
button:hover:not(:disabled) {
|
||||
background-color: var(--off-white-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user