Compare commits

..

No commits in common. "aa28f8d100b3ff7cabb2a659a2538730a4cb97d3" and "09490514b1dbc0a84875ac52a87ad02c79a664d0" have entirely different histories.

13 changed files with 166 additions and 299 deletions

View File

@ -0,0 +1,44 @@
#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;*/
/*}*/
#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%;
}

View File

@ -1,25 +1,67 @@
<h1>Upload Recipe</h1>
<app-recipe-upload-trail
<div id="recipe-upload-container">
<h1>Upload Recipe</h1>
<app-recipe-upload-trail
[displayStep]="displayStep()"
[inProgressStep]="inProgressStep()"
[includeInfer]="includeInfer()"
(stepClick)="onStepClick($event)"
></app-recipe-upload-trail>
></app-recipe-upload-trail>
@if (displayStep() === RecipeUploadStep.START) {
@if (displayStep() === RecipeUploadStep.START) {
<app-ai-or-manual
[sourceFile]="sourceFile()"
(sourceFileChange)="onSourceFileChange($event)"
(submitStep)="onAiOrManualSubmit($event)"
></app-ai-or-manual>
} @else if (displayStep() === RecipeUploadStep.INFER) {
} @else if (displayStep() === RecipeUploadStep.INFER) {
<app-infer></app-infer>
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
<app-enter-recipe-data
[model]="model()"
(submit)="onEnterRecipeDataSubmit($event)"
(deleteDraft)="onDeleteDraft()"
></app-enter-recipe-data>
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
} @else if (displayStep() === RecipeUploadStep.ENTER_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>
}
}
<!--
<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 matButton="outlined" type="button" (click)="onClear()">Clear</button>
<button matButton="filled" 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()">
<mat-form-field>
<mat-label>Recipe Title</mat-label>
<input matInput id="recipe-title" type="text" formControlName="title" />
</mat-form-field>
<mat-form-field>
<mat-label>Recipe Text</mat-label>
<textarea
matInput
id="recipe-text"
formControlName="recipeText"
(input)="onRecipeTextChange($event)"
></textarea>
</mat-form-field>
<button matButton="filled" 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>

View File

@ -13,9 +13,8 @@ import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
import { tryMaybeInt } from '../../shared/util';
import { from, map, switchMap, tap } from 'rxjs';
import { EnterRecipeDataEvent } from './steps/enter-recipe-data/EnterRecipeDataEvent';
import { Review } from './steps/review/review';
import { EnterRecipeDataSubmitEvent } from './steps/enter-recipe-data/EnterRecipeDataSubmitEvent';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Component({
selector: 'app-recipe-upload-page',
@ -36,7 +35,6 @@ export class RecipeUploadPage implements OnInit {
private readonly router = inject(Router);
private readonly activatedRoute = inject(ActivatedRoute);
private readonly recipeDraftService = inject(RecipeDraftService);
private readonly queryClient = inject(QueryClient);
private isValidStep(step: number): boolean {
if (this.model().draft?.lastInference || this.model().draft?.state === 'INFER') {
@ -96,10 +94,9 @@ export class RecipeUploadPage implements OnInit {
await this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {
draftId: this.model().draft?.id,
step: targetStep,
},
queryParamsHandling: 'replace',
queryParamsHandling: 'merge',
});
}
@ -140,22 +137,16 @@ export class RecipeUploadPage implements OnInit {
}
}
protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise<void> {
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
protected async onEnterRecipeDataEvent(event: EnterRecipeDataEvent): Promise<void> {
if (event._type === 'submit') {
const { title, slug, rawText } = event.data;
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, {
title,
slug,
rawText,
});
await this.switchModel(model, RecipeUploadStep.REVIEW);
}
protected async onDeleteDraft(): Promise<void> {
await this.recipeDraftService.deleteDraft(this.model().draft!.id);
await this.queryClient.invalidateQueries({
queryKey: ['recipe-upload', 'in-progress-drafts'],
});
await this.switchModel(
{
inProgressStep: RecipeUploadStep.START,
},
RecipeUploadStep.START,
);
}
protected async onPublish(): Promise<void> {

View File

@ -34,12 +34,6 @@ export class RecipeUploadTrail {
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA,
},
{
index: RecipeUploadStep.REVIEW,
name: 'Review',
completed: this.inProgressStep() > RecipeUploadStep.REVIEW,
inProgress: this.inProgressStep() === RecipeUploadStep.REVIEW,
},
];
if (this.includeInfer()) {
base.push({

View File

@ -1,12 +1,12 @@
<section>
<h2>Start</h2>
<section>
<h3>In Progress Drafts</h3>
@if (inProgressDrafts.isLoading()) {
<p>Loading drafts...</p>
} @else if (inProgressDrafts.isError()) {
<p>Could not fetch drafts!</p>
} @else if (inProgressDrafts.isSuccess() && inProgressDrafts.data().length) {
<h3>In Progress Drafts</h3>
} @else if (inProgressDrafts.isSuccess()) {
<ul>
@for (draft of inProgressDrafts.data(); track draft.id) {
<li>

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,10 +0,0 @@
export interface EnterRecipeDataSubmitEvent {
title: string;
slug: string;
ingredients: Array<{
amount: string | null;
name: string;
notes: string | null;
}>;
rawText: string;
}

View File

@ -1,11 +1,3 @@
.ingredients-table {
width: 100ch;
}
.ingredient-input {
width: 100%;
}
form {
display: flex;
flex-direction: column;
@ -18,8 +10,3 @@ textarea {
overflow: hidden;
resize: none;
}
.draft-info-container {
display: flex;
column-gap: 10px;
}

View File

@ -1,20 +1,5 @@
<h2>Enter Recipe</h2>
<div class="draft-info-container">
<div>
<p>Draft started: {{ model().draft!.created }}</p>
<p>Last saved: {{ model().draft!.modified }}</p>
</div>
<div>
<button matButton="text" [matMenuTriggerFor]="draftActionsMenu">
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
</button>
<mat-menu #draftActionsMenu="matMenu">
<button mat-menu-item (click)="onDraftDelete()">Delete draft</button>
</mat-menu>
</div>
</div>
<form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)">
<h3>Basic Info</h3>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.title" />
@ -23,63 +8,6 @@
<mat-label>Slug</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.slug" />
</mat-form-field>
<h3>Ingredients</h3>
<table
class="ingredients-table"
mat-table
#ingredientsTable
[dataSource]="recipeFormGroup.controls.ingredients.controls"
>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let row; let i = index">
<mat-form-field class="ingredient-input ingredient-amount-input">
<input
#ingredientAmount
type="text"
matInput
[formControl]="getIngredientControl(i, 'amount')"
(keydown)="onIngredientKeydown($event, i)"
/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let row; let i = index">
<mat-form-field class="ingredient-input ingredient-name-input">
<input
type="text"
matInput
[formControl]="getIngredientControl(i, 'name')"
(keydown)="onIngredientKeydown($event, i)"
/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="notes">
<th mat-header-cell *matHeaderCellDef>Notes</th>
<td mat-cell *matCellDef="let row; let i = index">
<mat-form-field class="ingredient-input ingredient-notes-input">
<input
type="text"
matInput
[formControl]="getIngredientControl(i, 'notes')"
(keydown)="onIngredientKeydown($event, i)"
/>
</mat-form-field>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="ingredientsColumnsToDisplay"></tr>
<tr mat-row *matRowDef="let row; columns: ingredientsColumnsToDisplay"></tr>
</table>
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
<h3>Recipe Text</h3>
<mat-form-field>
<mat-label>Recipe Text</mat-label>
<textarea

View File

@ -9,88 +9,24 @@ import {
output,
runInInjectionContext,
viewChild,
viewChildren,
} from '@angular/core';
import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel';
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { EnterRecipeDataSubmitEvent } from './EnterRecipeDataSubmitEvent';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
} from '@angular/material/table';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { EnterRecipeDataEvent } from './EnterRecipeDataEvent';
@Component({
selector: 'app-enter-recipe-data',
imports: [
ReactiveFormsModule,
MatFormField,
MatLabel,
MatInput,
MatButton,
MatTable,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatCell,
MatCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
FaIconComponent,
MatMenuTrigger,
MatMenu,
MatMenuItem,
],
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatButton],
templateUrl: './enter-recipe-data.html',
styleUrl: './enter-recipe-data.css',
})
export class EnterRecipeData implements OnInit {
public readonly model = input.required<RecipeUploadClientModel>();
public readonly submit = output<EnterRecipeDataSubmitEvent>();
public readonly deleteDraft = output<void>();
public readonly submit = output<EnterRecipeDataEvent>();
protected recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
protected ingredientsTable = viewChild.required<
MatTable<
FormGroup<{
amount: FormControl<string | null>;
name: FormControl<string | null>;
notes: FormControl<string | null>;
}>
>
>('ingredientsTable');
protected ingredientAmountControls = viewChildren<ElementRef<HTMLInputElement>>('ingredientAmount');
protected readonly recipeFormGroup = new FormGroup({
title: new FormControl('', Validators.required),
slug: new FormControl('', Validators.required),
ingredients: new FormArray(
[] as Array<
FormGroup<{
amount: FormControl<string | null>;
name: FormControl<string | null>;
notes: FormControl<string | null>;
}>
>,
),
text: new FormControl('', Validators.required),
});
protected readonly ingredientsColumnsToDisplay = ['amount', 'name', 'notes'];
private readonly injector = inject(Injector);
@ -100,7 +36,6 @@ export class EnterRecipeData implements OnInit {
title: model.draft?.title ?? '',
slug: model.draft?.slug ?? '',
text: model.draft?.rawText ?? '',
ingredients: model.draft?.ingredients ?? [],
});
runInInjectionContext(this.injector, () => {
afterNextRender({
@ -111,6 +46,12 @@ export class EnterRecipeData implements OnInit {
});
}
protected readonly recipeFormGroup = new FormGroup({
title: new FormControl('', Validators.required),
slug: new FormControl('', Validators.required),
text: new FormControl('', Validators.required),
});
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
const windowScrollX = window.scrollX;
const windowScrollY = window.scrollY;
@ -125,68 +66,16 @@ export class EnterRecipeData implements OnInit {
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
}
protected addIngredient() {
const control = new FormGroup({
amount: new FormControl(''),
name: new FormControl('', Validators.required),
notes: new FormControl(''),
});
this.recipeFormGroup.controls.ingredients.push(control);
this.ingredientsTable().renderRows();
const addedIndex = this.recipeFormGroup.controls.ingredients.length - 1;
const target = this.ingredientAmountControls()[addedIndex];
target.nativeElement.focus();
}
protected removeIngredient(index: number) {
this.recipeFormGroup.controls.ingredients.removeAt(index);
}
protected getIngredientControl(index: number, column: 'amount' | 'name' | 'notes'): FormControl {
const ingredientGroup = this.recipeFormGroup.controls.ingredients.controls[index].controls;
switch (column) {
case 'amount':
return ingredientGroup.amount;
case 'name':
return ingredientGroup.name;
case 'notes':
return ingredientGroup.notes;
}
}
protected onIngredientKeydown(event: KeyboardEvent, index: number) {
if (event.key === 'Enter') {
event.preventDefault();
this.onIngredientEnterKey(index);
}
}
private onIngredientEnterKey(index: number) {
if (index === this.recipeFormGroup.controls.ingredients.length - 1) {
// last control row
this.addIngredient();
}
}
protected onSubmit(event: SubmitEvent): void {
event.preventDefault();
const value = this.recipeFormGroup.value;
const { title, slug, text } = this.recipeFormGroup.value;
this.submit.emit({
title: value.title!,
slug: value.slug!,
ingredients:
value.ingredients?.map((ingredient) => ({
amount: ingredient.amount ?? null,
name: ingredient.name!,
notes: ingredient.notes ?? null,
})) ?? [],
rawText: value.text!,
_type: 'submit',
data: {
title: title!,
slug: slug!,
rawText: text!,
},
});
}
protected onDraftDelete(): void {
this.deleteDraft.emit();
}
protected readonly faEllipsis = faEllipsis;
}

View File

@ -8,8 +8,9 @@ describe('Logo', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Logo],
}).compileComponents();
imports: [Logo]
})
.compileComponents();
fixture = TestBed.createComponent(Logo);
component = fixture.componentInstance;

View File

@ -26,7 +26,7 @@
<div>{{ addCommentMutation.variables() }}</div>
</li>
}
@for (recipeComments of commentsQuery.data()!.pages; track $index) {
@for (recipeComments of commentsQuery.data()?.pages; track $index) {
@for (recipeComment of recipeComments.content; track recipeComment.id) {
<li class="comment">
<div class="comment-username-time">

View File

@ -77,11 +77,6 @@ export class RecipeDraftService {
data: {
title?: string | null;
slug?: string | null;
ingredients?: Array<{
amount?: string | null;
name: string;
notes?: string | null;
}>;
rawText?: string | null;
},
): Promise<RecipeUploadClientModel> {
@ -104,10 +99,6 @@ export class RecipeDraftService {
);
}
public deleteDraft(id: string): Promise<void> {
return firstValueFrom(this.http.delete<void>(this.endpointService.getUrl('recipeDrafts', [id])));
}
public doInference(model: RecipeUploadClientModel): Observable<RecipeUploadClientModel> {
return of({
inProgressStep: RecipeUploadStep.ENTER_DATA,