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.
+
+
+
+
+
+
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;
}