diff --git a/package.json b/package.json index a9aa721..79c0fbc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@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/endpoints.ts b/src/app/endpoints.ts index 9d229c8..42b2aea 100644 --- a/src/app/endpoints.ts +++ b/src/app/endpoints.ts @@ -3,4 +3,5 @@ export const Endpoints = { authLogout: 'auth/logout', authRefresh: 'auth/refresh', recipes: 'recipes', + recipeDrafts: 'recipe-drafts', }; diff --git a/src/app/pages/recipe-upload-page/recipe-upload-page.css b/src/app/pages/recipe-upload-page/recipe-upload-page.css index 0a3e589..9a0b03e 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.css +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.css @@ -31,13 +31,6 @@ form { /* 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; diff --git a/src/app/pages/recipe-upload-page/recipe-upload-page.ts b/src/app/pages/recipe-upload-page/recipe-upload-page.ts index d828963..62756a9 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.ts +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.ts @@ -7,8 +7,8 @@ import { EnterRecipeData } from './steps/enter-recipe-data/enter-recipe-data'; import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail'; import { ActivatedRoute, Router } from '@angular/router'; import { StepClickEvent } from './recipe-upload-trail/StepClickEvent'; -import { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel'; -import { RecipeUploadService } from '../../shared/services/RecipeUploadService'; +import { RecipeUploadClientModel } from '../../shared/client-models/RecipeUploadClientModel'; +import { RecipeDraftService } from '../../shared/services/RecipeDraftService'; import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep'; import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent'; import { tryMaybeInt } from '../../shared/util'; @@ -21,35 +21,33 @@ import { from, map, switchMap, tap } from 'rxjs'; styleUrl: './recipe-upload-page.css', }) export class RecipeUploadPage implements OnInit { - protected readonly model = signal({ + protected readonly model = signal({ inProgressStep: RecipeUploadStep.START, }); protected readonly displayStep = signal(RecipeUploadStep.START); protected readonly inProgressStep = computed(() => this.model().inProgressStep); protected readonly includeInfer = signal(false); - protected readonly sourceFile = computed(() => this.model().sourceFile ?? null); + protected readonly sourceFile = computed(() => this.model().inputSourceFile ?? null); private readonly router = inject(Router); private readonly activatedRoute = inject(ActivatedRoute); - private readonly recipeUploadService = inject(RecipeUploadService); + private readonly recipeUploadService = inject(RecipeDraftService); public ngOnInit(): void { this.activatedRoute.queryParamMap .pipe( map((paramMap) => { - const draftIdParam: string | null = paramMap.get('draftId'); - const draftId = tryMaybeInt(draftIdParam); const stepParam: string | null = paramMap.get('step'); const step = tryMaybeInt(stepParam); - return [draftId, step]; + return [paramMap.get('draftId'), step] as const; }), switchMap(([draftId, step]) => { const currentModel = this.model(); - if (draftId !== null && currentModel.id !== draftId) { - return this.recipeUploadService.getRecipeUploadModel(draftId).pipe( - tap((updatedModel) => { - this.model.set(updatedModel); + if (draftId !== null) { + return this.recipeUploadService.getRecipeUploadClientModel(draftId).pipe( + tap((recipeUploadClientModel) => { + this.switchModel(recipeUploadClientModel); }), switchMap((updatedModel) => { if (step !== null && step <= updatedModel.inProgressStep) { @@ -69,13 +67,17 @@ export class RecipeUploadPage implements OnInit { .subscribe(); } + private switchModel(model: RecipeUploadClientModel): void { + this.model.set(model); + this.includeInfer.set(!!model.draft?.lastInference); + } + private async changeDisplayStep(targetStep: number): Promise { this.displayStep.set(targetStep); await this.router.navigate([], { relativeTo: this.activatedRoute, queryParams: { step: targetStep, - draftId: this.model().id, }, queryParamsHandling: 'merge', }); @@ -89,12 +91,12 @@ export class RecipeUploadPage implements OnInit { if (event._tag === 'file-add-event') { this.model.update((model) => ({ ...model, - sourceFile: event.file, + inputSourceFile: event.file, })); } else { this.model.update((model) => ({ ...model, - sourceFile: null, + inputSourceFile: null, })); } } @@ -103,7 +105,7 @@ export class RecipeUploadPage implements OnInit { if (event.mode === 'manual') { this.model.update((model) => ({ ...model, - sourceFile: null, + inputSourceFile: null, inProgressStep: RecipeUploadStep.ENTER_DATA, })); await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA); @@ -111,7 +113,7 @@ export class RecipeUploadPage implements OnInit { } else { this.model.update((model) => ({ ...model, - sourceFile: this.sourceFile(), + inputSourceFile: this.sourceFile(), inProgressStep: RecipeUploadStep.INFER, })); await this.changeDisplayStep(RecipeUploadStep.INFER); diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html index 7e49f62..b64105d 100644 --- a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html +++ b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.html @@ -1,13 +1,35 @@

Start

-

Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.

-
- -
- - -
-
+
+

In Progress Drafts

+ @if (inProgressDrafts.isLoading()) { +

Loading drafts...

+ } @else if (inProgressDrafts.isError()) { +

Could not fetch drafts!

+ } @else if (inProgressDrafts.isSuccess()) { + + } +
+
+

New Draft

+

Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.

+
+ +
+ + +
+
+
diff --git a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts index 62c43b9..86e62bb 100644 --- a/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts +++ b/src/app/pages/recipe-upload-page/steps/ai-or-manual/ai-or-manual.ts @@ -1,13 +1,19 @@ -import { Component, computed, input, output } from '@angular/core'; +import { Component, computed, inject, input, output } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { ReactiveFormsModule } from '@angular/forms'; import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent'; import { FileUpload } from '../../../../shared/components/file-upload/file-upload'; import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +import { RecipeDraftService } from '../../../../shared/services/RecipeDraftService'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faFilePen } from '@fortawesome/free-solid-svg-icons'; +import { Router } from '@angular/router'; +import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model'; @Component({ selector: 'app-ai-or-manual', - imports: [MatButton, ReactiveFormsModule, FileUpload], + imports: [MatButton, ReactiveFormsModule, FileUpload, FaIconComponent], templateUrl: './ai-or-manual.html', styleUrl: './ai-or-manual.css', }) @@ -16,6 +22,9 @@ export class AiOrManual { public sourceFileChange = output(); public submitStep = output(); + private readonly recipeDraftService = inject(RecipeDraftService); + private readonly router = inject(Router); + protected readonly sourceFilesArray = computed(() => { const maybeSourceFile = this.sourceFile(); if (maybeSourceFile) { @@ -25,6 +34,13 @@ export class AiOrManual { } }); + protected readonly inProgressDrafts = injectQuery(() => ({ + queryKey: ['recipe-upload', 'in-progress-drafts'], + queryFn: () => { + return this.recipeDraftService.getInProgressDrafts(); + }, + })); + protected onFileChange(event: FileUploadEvent) { this.sourceFileChange.emit(event); } @@ -32,4 +48,14 @@ export class AiOrManual { protected onFormSubmit(mode: 'manual' | 'ai-assist') { this.submitStep.emit({ mode }); } + + protected async onInProgressDraftClick(draft: RecipeDraftViewModel) { + await this.router.navigate(['/recipe-upload'], { + queryParams: { + draftId: draft.id, + }, + }); + } + + protected readonly faFilePen = faFilePen; } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css index 12113fc..e0ddf0a 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css @@ -3,3 +3,10 @@ form { flex-direction: column; width: 60ch; } + +textarea { + box-sizing: border-box; + height: auto; + overflow: hidden; + resize: none; +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html index d9608ad..07b9385 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html @@ -10,6 +10,11 @@ Recipe Text - + diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts index d9e1e02..22df255 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts @@ -1,5 +1,15 @@ -import { Component, input, OnInit } from '@angular/core'; -import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel'; +import { + afterNextRender, + Component, + ElementRef, + inject, + Injector, + input, + OnInit, + runInInjectionContext, + viewChild, +} from '@angular/core'; +import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; @@ -10,14 +20,25 @@ import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; styleUrl: './enter-recipe-data.css', }) export class EnterRecipeData implements OnInit { - public readonly model = input.required(); + public readonly model = input.required(); + + protected recipeTextTextarea = viewChild.required>('recipeTextTextarea'); + + private readonly injector = inject(Injector); public ngOnInit(): void { const model = this.model(); this.recipeFormGroup.patchValue({ - title: model.userTitle ?? model.inferredTitle ?? '', - slug: model.userSlug ?? model.inferredSlug ?? '', - text: model.userText ?? model.inferredText ?? '', + title: model.draft?.title ?? '', + slug: model.draft?.slug ?? '', + text: model.draft?.rawText ?? '', + }); + runInInjectionContext(this.injector, () => { + afterNextRender({ + mixedReadWrite: () => { + this.updateTextareaHeight(this.recipeTextTextarea().nativeElement); + }, + }); }); } @@ -26,4 +47,18 @@ export class EnterRecipeData implements OnInit { slug: new FormControl('', Validators.required), text: new FormControl('', Validators.required), }); + + private updateTextareaHeight(textarea: HTMLTextAreaElement) { + const windowScrollX = window.scrollX; + const windowScrollY = window.scrollY; + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + requestAnimationFrame(() => { + window.scrollTo(windowScrollX, windowScrollY); + }); + } + + protected onRecipeTextChange(event: Event): void { + this.updateTextareaHeight(event.target as HTMLTextAreaElement); + } } diff --git a/src/app/shared/client-models/RecipeUploadClientModel.ts b/src/app/shared/client-models/RecipeUploadClientModel.ts new file mode 100644 index 0000000..2761bec --- /dev/null +++ b/src/app/shared/client-models/RecipeUploadClientModel.ts @@ -0,0 +1,8 @@ +import { RecipeUploadStep } from './RecipeUploadStep'; +import { RecipeDraftViewModel } from '../models/RecipeDraftView.model'; + +export interface RecipeUploadClientModel { + inProgressStep: RecipeUploadStep; + inputSourceFile?: File | null; + draft?: RecipeDraftViewModel; +} diff --git a/src/app/shared/client-models/RecipeUploadIngredientModel.ts b/src/app/shared/client-models/RecipeUploadIngredientModel.ts deleted file mode 100644 index f979e12..0000000 --- a/src/app/shared/client-models/RecipeUploadIngredientModel.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface RecipeUploadIngredientModel { - amount: string | null; - name: string; - notes: string | null; -} diff --git a/src/app/shared/client-models/RecipeUploadModel.ts b/src/app/shared/client-models/RecipeUploadModel.ts deleted file mode 100644 index 010dc89..0000000 --- a/src/app/shared/client-models/RecipeUploadModel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { RecipeUploadIngredientModel } from './RecipeUploadIngredientModel'; -import { RecipeUploadStep } from './RecipeUploadStep'; - -export interface RecipeUploadModel { - inProgressStep: RecipeUploadStep; - - id?: number | null; - sourceFile?: File | null; - - inferredText?: string | null; - inferredIngredients?: RecipeUploadIngredientModel[] | null; - inferredTitle?: string | null; - inferredSlug?: string | null; - - userText?: string | null; - userIngredients?: RecipeUploadIngredientModel[] | null; - userTitle?: string | null; - userSlug?: string | null; -} diff --git a/src/app/shared/guards/auth-guard.ts b/src/app/shared/guards/auth-guard.ts index 80b96f7..32e1a57 100644 --- a/src/app/shared/guards/auth-guard.ts +++ b/src/app/shared/guards/auth-guard.ts @@ -1,8 +1,13 @@ -import { CanActivateFn } from '@angular/router'; +import { CanActivateFn, RedirectCommand, Router } from '@angular/router'; import { inject } from '@angular/core'; import { AuthService } from '../services/AuthService'; export const authGuard: CanActivateFn = (route, state) => { const authService = inject(AuthService); - return authService.accessToken() !== null; + if (authService.accessToken() === null) { + const router = inject(Router); + return new RedirectCommand(router.parseUrl('/')); + } else { + return true; + } }; diff --git a/src/app/shared/models/RecipeDraftView.model.ts b/src/app/shared/models/RecipeDraftView.model.ts new file mode 100644 index 0000000..64f25e5 --- /dev/null +++ b/src/app/shared/models/RecipeDraftView.model.ts @@ -0,0 +1,31 @@ +import { ResourceOwner } from './ResourceOwner.model'; +import { ImageView } from './ImageView.model'; + +export interface RecipeDraftViewModel { + id: string; + created: Date; + modified?: Date | null; + state: 'INFER' | 'ENTER_DATA'; + slug?: string | null; + title?: string | null; + preparationTime?: number | null; + cookingTime?: number | null; + totalTime?: number | null; + rawText?: string | null; + ingredients?: IngredientDraft[] | null; + owner: ResourceOwner; + mainImage?: ImageView | null; + lastInference?: RecipeDraftInferenceView | null; +} + +export interface IngredientDraft { + amount?: string | null; + name: string; + notes?: string | null; +} + +export interface RecipeDraftInferenceView { + inferredAt: Date; + title: string; + rawText: string; +} diff --git a/src/app/shared/services/RecipeDraftService.ts b/src/app/shared/services/RecipeDraftService.ts new file mode 100644 index 0000000..a67e2e4 --- /dev/null +++ b/src/app/shared/services/RecipeDraftService.ts @@ -0,0 +1,68 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { delay, firstValueFrom, map, Observable, of } from 'rxjs'; +import { RecipeUploadClientModel } from '../client-models/RecipeUploadClientModel'; +import { RecipeUploadStep } from '../client-models/RecipeUploadStep'; +import { RecipeDraftViewModel } from '../models/RecipeDraftView.model'; +import { EndpointService } from './EndpointService'; +import { WithStringDates } from '../util'; + +@Injectable({ + providedIn: 'root', +}) +export class RecipeDraftService { + private readonly http = inject(HttpClient); + private readonly endpointService = inject(EndpointService); + + private hydrateView(rawView: WithStringDates): RecipeDraftViewModel { + return { + ...rawView, + created: new Date(rawView.created), + modified: rawView.modified ? new Date(rawView.modified) : undefined, + lastInference: rawView.lastInference + ? { + ...rawView.lastInference, + inferredAt: new Date(rawView.lastInference.inferredAt), + } + : undefined, + }; + } + + public getInProgressDrafts(): Promise { + return firstValueFrom( + this.http.get[]>(this.endpointService.getUrl('recipeDrafts')).pipe( + map((rawViews) => { + return rawViews.map((rawView) => this.hydrateView(rawView)); + }), + ), + ); + } + + public getRecipeUploadClientModel(draftId: string): Observable { + return this.http + .get>(this.endpointService.getUrl('recipeDrafts', [draftId])) + .pipe( + map((rawDraft) => { + return this.hydrateView(rawDraft); + }), + map((draft) => { + return { + draft, + inProgressStep: + draft.state === 'ENTER_DATA' ? RecipeUploadStep.ENTER_DATA : RecipeUploadStep.INFER, + }; + }), + ); + } + + public doInference(model: RecipeUploadClientModel): Observable { + return of({ + inProgressStep: RecipeUploadStep.ENTER_DATA, + id: 16, + inferredTitle: 'Some recipe', + inferredSlug: 'some-recipe', + inferredText: 'Some text.', + inferredIngredients: [], + }).pipe(delay(5_000)); + } +} diff --git a/src/app/shared/services/RecipeUploadService.ts b/src/app/shared/services/RecipeUploadService.ts deleted file mode 100644 index 09b1758..0000000 --- a/src/app/shared/services/RecipeUploadService.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { delay, Observable, of } from 'rxjs'; -import { RecipeUploadModel } from '../client-models/RecipeUploadModel'; -import { RecipeUploadStep } from '../client-models/RecipeUploadStep'; - -@Injectable({ - providedIn: 'root', -}) -export class RecipeUploadService { - private readonly http = inject(HttpClient); - - public getRecipeUploadModel(draftId: number): Observable { - return of({ - inProgressStep: RecipeUploadStep.ENTER_DATA, - id: 42, - }); - } - - public doInference(model: RecipeUploadModel): Observable { - return of({ - inProgressStep: RecipeUploadStep.ENTER_DATA, - id: 16, - inferredTitle: 'Some recipe', - inferredSlug: 'some-recipe', - inferredText: 'Some text.', - inferredIngredients: [], - }).pipe(delay(5_000)); - } -} diff --git a/src/app/shared/util.ts b/src/app/shared/util.ts index d035de7..3208242 100644 --- a/src/app/shared/util.ts +++ b/src/app/shared/util.ts @@ -21,3 +21,15 @@ export const tryMaybeInt = (maybeString: string | null): number | null => { export const hasValue = (value?: T | null): value is T => { return value !== undefined && value !== null; }; + +export type WithStringDates = { + [K in keyof T]: T[K] extends Date + ? string + : T[K] extends Date | null | undefined + ? string | null | undefined + : T[K] extends Record + ? WithStringDates + : T[K] extends Record | null | undefined + ? WithStringDates | null | undefined + : T[K]; +};