All steps basically working, much more todo.

This commit is contained in:
Jesse Brault 2026-01-28 18:17:31 -06:00
parent ff9ffd9e13
commit b42971703f
17 changed files with 212 additions and 127 deletions

View File

@ -31,13 +31,18 @@
</div> </div>
</div> </div>
@if (mainImageUrl.isSuccess()) { @if (mainImageUrl.isSuccess()) {
@let maybeMainImageUrl = mainImageUrl.data();
@if (!!maybeMainImageUrl) {
<img <img
id="main-image" id="main-image"
[src]="mainImageUrl.data()" [src]="maybeMainImageUrl"
[alt]="recipe.mainImage.alt" [alt]="recipe.mainImage!.alt"
[height]="recipe.mainImage.height" [height]="recipe.mainImage!.height"
[width]="recipe.mainImage.width" [width]="recipe.mainImage!.width"
/> />
} @else {
<p>!! Placeholder todo !!</p>
}
} }
<div [innerHTML]="recipe.text"></div> <div [innerHTML]="recipe.text"></div>
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" /> <app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />

View File

@ -27,8 +27,8 @@ export class RecipePageContent {
protected readonly mainImageUrl = injectQuery(() => { protected readonly mainImageUrl = injectQuery(() => {
const recipe = this.recipeView().recipe; const recipe = this.recipeView().recipe;
return { return {
queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename], queryKey: ['recipe-main-images', recipe.owner.username, recipe.slug],
queryFn: () => this.imageService.getImage(recipe.mainImage.url), queryFn: () => this.imageService.getImage(recipe.mainImage?.url)
}; };
}); });

View File

@ -16,7 +16,9 @@
} @else if (displayStep() === RecipeUploadStep.INFER) { } @else if (displayStep() === RecipeUploadStep.INFER) {
<app-infer></app-infer> <app-infer></app-infer>
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) { } @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
<app-enter-recipe-data [model]="model()"></app-enter-recipe-data> <app-enter-recipe-data [model]="model()" (submit)="onEnterRecipeDataEvent($event)"></app-enter-recipe-data>
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
<app-review [draft]="model().draft!" (publish)="onPublish()"></app-review>
} }
<!-- <!--

View File

@ -13,10 +13,12 @@ import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent'; import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
import { tryMaybeInt } from '../../shared/util'; import { tryMaybeInt } from '../../shared/util';
import { from, map, switchMap, tap } from 'rxjs'; import { from, map, switchMap, tap } from 'rxjs';
import { EnterRecipeDataEvent } from './steps/enter-recipe-data/EnterRecipeDataEvent';
import { Review } from './steps/review/review';
@Component({ @Component({
selector: 'app-recipe-upload-page', selector: 'app-recipe-upload-page',
imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail], imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail, Review],
templateUrl: './recipe-upload-page.html', templateUrl: './recipe-upload-page.html',
styleUrl: './recipe-upload-page.css', styleUrl: './recipe-upload-page.css',
}) })
@ -32,7 +34,15 @@ export class RecipeUploadPage implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly activatedRoute = inject(ActivatedRoute); private readonly activatedRoute = inject(ActivatedRoute);
private readonly recipeUploadService = inject(RecipeDraftService); private readonly recipeDraftService = inject(RecipeDraftService);
private isValidStep(step: number): boolean {
if (this.model().draft?.lastInference || this.model().draft?.state === 'INFER') {
return step <= RecipeUploadStep.REVIEW;
} else {
return [0, 2, 3].includes(step);
}
}
public ngOnInit(): void { public ngOnInit(): void {
this.activatedRoute.queryParamMap this.activatedRoute.queryParamMap
@ -43,21 +53,20 @@ export class RecipeUploadPage implements OnInit {
return [paramMap.get('draftId'), step] as const; return [paramMap.get('draftId'), step] as const;
}), }),
switchMap(([draftId, step]) => { switchMap(([draftId, step]) => {
const currentModel = this.model();
if (draftId !== null) { if (draftId !== null) {
return this.recipeUploadService.getRecipeUploadClientModel(draftId).pipe( return this.recipeDraftService.getRecipeUploadClientModel(draftId).pipe(
tap((recipeUploadClientModel) => { tap(async (recipeUploadClientModel) => {
this.switchModel(recipeUploadClientModel); await this.switchModel(recipeUploadClientModel);
}), }),
switchMap((updatedModel) => { switchMap((updatedModel) => {
if (step !== null && step <= updatedModel.inProgressStep) { if (step !== null && this.isValidStep(step)) {
return from(this.changeDisplayStep(step)); return from(this.changeDisplayStep(step));
} else { } else {
return from(this.changeDisplayStep(updatedModel.inProgressStep)); return from(this.changeDisplayStep(updatedModel.inProgressStep));
} }
}), }),
); );
} else if (step !== null && step <= currentModel.inProgressStep) { } else if (step !== null && this.isValidStep(step)) {
return from(this.changeDisplayStep(step)); return from(this.changeDisplayStep(step));
} else { } else {
return from(this.changeDisplayStep(RecipeUploadStep.START)); return from(this.changeDisplayStep(RecipeUploadStep.START));
@ -67,9 +76,14 @@ export class RecipeUploadPage implements OnInit {
.subscribe(); .subscribe();
} }
private switchModel(model: RecipeUploadClientModel): void { private async switchModel(model: RecipeUploadClientModel, switchStep: boolean | RecipeUploadStep = false): Promise<void> {
this.model.set(model); this.model.set(model);
this.includeInfer.set(!!model.draft?.lastInference); this.includeInfer.set(!!model.draft?.lastInference);
if (switchStep === true) {
await this.changeDisplayStep(model.inProgressStep);
} else if (typeof switchStep === 'number') {
await this.changeDisplayStep(switchStep);
}
} }
private async changeDisplayStep(targetStep: number): Promise<void> { private async changeDisplayStep(targetStep: number): Promise<void> {
@ -103,13 +117,8 @@ export class RecipeUploadPage implements OnInit {
protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> { protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> {
if (event.mode === 'manual') { if (event.mode === 'manual') {
this.model.update((model) => ({ const model = await this.recipeDraftService.createManualDraft();
...model, await this.switchModel(model, true);
inputSourceFile: null,
inProgressStep: RecipeUploadStep.ENTER_DATA,
}));
await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
this.includeInfer.set(false);
} else { } else {
this.model.update((model) => ({ this.model.update((model) => ({
...model, ...model,
@ -118,97 +127,29 @@ export class RecipeUploadPage implements OnInit {
})); }));
await this.changeDisplayStep(RecipeUploadStep.INFER); await this.changeDisplayStep(RecipeUploadStep.INFER);
this.includeInfer.set(true); this.includeInfer.set(true);
this.recipeUploadService.doInference(this.model()).subscribe((updatedModel) => { this.recipeDraftService.doInference(this.model()).subscribe((updatedModel) => {
this.model.set(updatedModel); this.model.set(updatedModel);
this.changeDisplayStep(RecipeUploadStep.ENTER_DATA); this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
}); });
} }
} }
// private readonly sseClient = inject(SseClient); protected async onEnterRecipeDataEvent(event: EnterRecipeDataEvent): Promise<void> {
// private readonly formBuilder = inject(FormBuilder); if (event._type === 'submit') {
// const { title, slug, rawText } = event.data;
// protected readonly sourceRecipeImage = signal<string | null>(null); const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, {
// protected readonly inferenceInProgress = signal(false); title,
// slug,
// protected readonly recipeUploadForm = this.formBuilder.group({ rawText
// file: this.formBuilder.control<File | null>(null, [Validators.required]), });
// }); await this.switchModel(model, RecipeUploadStep.REVIEW);
// }
// protected readonly recipeForm = new FormGroup({ }
// title: new FormControl('', [Validators.required]),
// recipeText: new FormControl('', Validators.required), protected async onPublish(): Promise<void> {
// }); const recipe = await this.recipeDraftService.publish(this.model().draft!.id);
// await this.router.navigate(['recipes', recipe.owner.username, recipe.slug]);
// 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';
// }
protected readonly RecipeUploadStep = RecipeUploadStep; protected readonly RecipeUploadStep = RecipeUploadStep;
} }

View File

@ -0,0 +1,10 @@
export type EnterRecipeDataEvent = EnterRecipeDataSubmitEvent
export interface EnterRecipeDataSubmitEvent {
_type: 'submit';
data: {
title: string;
slug: string;
rawText: string;
}
}

View File

@ -1,5 +1,5 @@
<h2>Enter Recipe</h2> <h2>Enter Recipe</h2>
<form [formGroup]="recipeFormGroup"> <form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)">
<mat-form-field> <mat-form-field>
<mat-label>Title</mat-label> <mat-label>Title</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.title" /> <input matInput [formControl]="recipeFormGroup.controls.title" />
@ -17,4 +17,5 @@
(input)="onRecipeTextChange($event)" (input)="onRecipeTextChange($event)"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
<button matButton="filled" type="submit">Review</button>
</form> </form>

View File

@ -5,22 +5,25 @@ import {
inject, inject,
Injector, Injector,
input, input,
OnInit, OnInit, output,
runInInjectionContext, runInInjectionContext,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel'; import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { EnterRecipeDataEvent } from './EnterRecipeDataEvent';
@Component({ @Component({
selector: 'app-enter-recipe-data', selector: 'app-enter-recipe-data',
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput], imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatButton],
templateUrl: './enter-recipe-data.html', templateUrl: './enter-recipe-data.html',
styleUrl: './enter-recipe-data.css', styleUrl: './enter-recipe-data.css',
}) })
export class EnterRecipeData implements OnInit { export class EnterRecipeData implements OnInit {
public readonly model = input.required<RecipeUploadClientModel>(); public readonly model = input.required<RecipeUploadClientModel>();
public readonly submit = output<EnterRecipeDataEvent>();
protected recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea'); protected recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
@ -61,4 +64,17 @@ export class EnterRecipeData implements OnInit {
protected onRecipeTextChange(event: Event): void { protected onRecipeTextChange(event: Event): void {
this.updateTextareaHeight(event.target as HTMLTextAreaElement); this.updateTextareaHeight(event.target as HTMLTextAreaElement);
} }
protected onSubmit(event: SubmitEvent): void {
event.preventDefault();
const { title, slug, text } = this.recipeFormGroup.value;
this.submit.emit({
_type: 'submit',
data: {
title: title!,
slug: slug!,
rawText: text!
}
})
}
} }

View File

@ -0,0 +1,9 @@
<section>
<h2>Review and Publish</h2>
<p>Title: {{ draft().title }}</p>
<p>Slug: {{ draft().slug }}</p>
<div>
<p>Text: todo</p>
</div>
<button matButton="filled" (click)="onPublish()">Publish</button>
</section>

View File

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

View File

@ -0,0 +1,22 @@
import { Component, input, output } from '@angular/core';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
import { MatButton } from '@angular/material/button';
@Component({
selector: 'app-review',
imports: [
MatButton
],
templateUrl: './review.html',
styleUrl: './review.css',
})
export class Review {
public readonly draft = input.required<RecipeDraftViewModel>();
public readonly publish = output<void>();
protected onPublish(): void {
this.publish.emit();
}
}

View File

@ -2,4 +2,5 @@ export enum RecipeUploadStep {
START, START,
INFER, INFER,
ENTER_DATA, ENTER_DATA,
REVIEW
} }

View File

@ -2,7 +2,12 @@
<article> <article>
<a [routerLink]="recipePageLink()"> <a [routerLink]="recipePageLink()">
@if (mainImage.isSuccess()) { @if (mainImage.isSuccess()) {
<img [src]="mainImage.data()" id="recipe-card-image" [alt]="recipe.mainImage.alt" /> @let maybeMainImageUrl = mainImage.data();
@if (!!maybeMainImageUrl) {
<img [src]="mainImage.data()" id="recipe-card-image" [alt]="recipe.mainImage?.alt" />
} @else {
<p>!! Placeholder todo !!</p>
}
} }
</a> </a>
<div id="title-and-visibility"> <div id="title-and-visibility">

View File

@ -30,8 +30,8 @@ export class RecipeCard {
protected readonly mainImage = injectQuery(() => { protected readonly mainImage = injectQuery(() => {
const recipe = this.recipe(); const recipe = this.recipe();
return { return {
queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename], queryKey: ['recipe-main-images', recipe.owner.username, recipe.slug],
queryFn: () => this.imageService.getImage(recipe.mainImage.url), queryFn: () => this.imageService.getImage(recipe.mainImage?.url)
}; };
}); });
} }

View File

@ -18,7 +18,7 @@ export interface RecipeView {
export interface Recipe { export interface Recipe {
id: number; id: number;
isPublic: boolean; isPublic: boolean;
mainImage: ImageView; mainImage?: ImageView | null;
owner: ResourceOwner; owner: ResourceOwner;
slug: string; slug: string;
starCount: number; starCount: number;

View File

@ -8,7 +8,8 @@ import { firstValueFrom, map } from 'rxjs';
export class ImageService { export class ImageService {
private readonly httpClient = inject(HttpClient); private readonly httpClient = inject(HttpClient);
public getImage(backendUrl: string): Promise<string> { public getImage(backendUrl?: string | null): Promise<string | null> {
if (!!backendUrl) {
return firstValueFrom( return firstValueFrom(
this.httpClient this.httpClient
.get(backendUrl, { .get(backendUrl, {
@ -16,5 +17,8 @@ export class ImageService {
}) })
.pipe(map((blob) => URL.createObjectURL(blob))), .pipe(map((blob) => URL.createObjectURL(blob))),
); );
} else {
return Promise.resolve(null);
}
} }
} }

View File

@ -6,6 +6,7 @@ import { RecipeUploadStep } from '../client-models/RecipeUploadStep';
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model'; import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
import { EndpointService } from './EndpointService'; import { EndpointService } from './EndpointService';
import { WithStringDates } from '../util'; import { WithStringDates } from '../util';
import { Recipe } from '../models/Recipe.model';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -55,6 +56,51 @@ export class RecipeDraftService {
); );
} }
public createManualDraft(): Promise<RecipeUploadClientModel> {
return firstValueFrom(
this.http
.post<
WithStringDates<RecipeDraftViewModel>
>(this.endpointService.getUrl('recipeDrafts', ['manual']), null)
.pipe(
map((rawDraft) => this.hydrateView(rawDraft)),
map((draft) => ({
draft,
inProgressStep: RecipeUploadStep.ENTER_DATA,
})),
),
);
}
public updateDraft(id: string, data: {
title?: string | null;
slug?: string | null;
rawText?: string | null;
}): Promise<RecipeUploadClientModel> {
return firstValueFrom(
this.http.put<WithStringDates<RecipeDraftViewModel>>(
this.endpointService.getUrl('recipeDrafts', [id]),
data
)
.pipe(
map(rawView => this.hydrateView(rawView)),
map(draft => ({
draft,
inProgressStep: RecipeUploadStep.ENTER_DATA,
}))
)
);
}
public publish(id: string): Promise<Recipe> {
return firstValueFrom(
this.http.post<Recipe>(
this.endpointService.getUrl('recipeDrafts', [id, 'publish']),
null
)
);
}
public doInference(model: RecipeUploadClientModel): Observable<RecipeUploadClientModel> { public doInference(model: RecipeUploadClientModel): Observable<RecipeUploadClientModel> {
return of({ return of({
inProgressStep: RecipeUploadStep.ENTER_DATA, inProgressStep: RecipeUploadStep.ENTER_DATA,