Compare commits
2 Commits
09490514b1
...
aa28f8d100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa28f8d100 | ||
|
|
c02e78d27d |
@ -1,44 +0,0 @@
|
||||
#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%;
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
<div id="recipe-upload-container">
|
||||
<h1>Upload Recipe</h1>
|
||||
<app-recipe-upload-trail
|
||||
[displayStep]="displayStep()"
|
||||
@ -16,52 +15,11 @@
|
||||
} @else if (displayStep() === RecipeUploadStep.INFER) {
|
||||
<app-infer></app-infer>
|
||||
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||
<app-enter-recipe-data [model]="model()" (submit)="onEnterRecipeDataEvent($event)"></app-enter-recipe-data>
|
||||
<app-enter-recipe-data
|
||||
[model]="model()"
|
||||
(submit)="onEnterRecipeDataSubmit($event)"
|
||||
(deleteDraft)="onDeleteDraft()"
|
||||
></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>
|
||||
|
||||
@ -13,8 +13,9 @@ 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',
|
||||
@ -35,6 +36,7 @@ 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') {
|
||||
@ -94,9 +96,10 @@ export class RecipeUploadPage implements OnInit {
|
||||
await this.router.navigate([], {
|
||||
relativeTo: this.activatedRoute,
|
||||
queryParams: {
|
||||
draftId: this.model().draft?.id,
|
||||
step: targetStep,
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
queryParamsHandling: 'replace',
|
||||
});
|
||||
}
|
||||
|
||||
@ -137,16 +140,22 @@ export class RecipeUploadPage implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise<void> {
|
||||
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
|
||||
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> {
|
||||
|
||||
@ -34,6 +34,12 @@ 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({
|
||||
|
||||
@ -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()) {
|
||||
} @else if (inProgressDrafts.isSuccess() && inProgressDrafts.data().length) {
|
||||
<h3>In Progress Drafts</h3>
|
||||
<ul>
|
||||
@for (draft of inProgressDrafts.data(); track draft.id) {
|
||||
<li>
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
export type EnterRecipeDataEvent = EnterRecipeDataSubmitEvent;
|
||||
|
||||
export interface EnterRecipeDataSubmitEvent {
|
||||
_type: 'submit';
|
||||
data: {
|
||||
title: string;
|
||||
slug: string;
|
||||
rawText: string;
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
export interface EnterRecipeDataSubmitEvent {
|
||||
title: string;
|
||||
slug: string;
|
||||
ingredients: Array<{
|
||||
amount: string | null;
|
||||
name: string;
|
||||
notes: string | null;
|
||||
}>;
|
||||
rawText: string;
|
||||
}
|
||||
@ -1,3 +1,11 @@
|
||||
.ingredients-table {
|
||||
width: 100ch;
|
||||
}
|
||||
|
||||
.ingredient-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -10,3 +18,8 @@ textarea {
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.draft-info-container {
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
@ -1,5 +1,20 @@
|
||||
<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" />
|
||||
@ -8,6 +23,63 @@
|
||||
<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
|
||||
|
||||
@ -9,24 +9,88 @@ import {
|
||||
output,
|
||||
runInInjectionContext,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { EnterRecipeDataEvent } from './EnterRecipeDataEvent';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-enter-recipe-data',
|
||||
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput, MatButton],
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatButton,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
MatHeaderCellDef,
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
FaIconComponent,
|
||||
MatMenuTrigger,
|
||||
MatMenu,
|
||||
MatMenuItem,
|
||||
],
|
||||
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<EnterRecipeDataEvent>();
|
||||
public readonly submit = output<EnterRecipeDataSubmitEvent>();
|
||||
public readonly deleteDraft = output<void>();
|
||||
|
||||
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);
|
||||
|
||||
@ -36,6 +100,7 @@ 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({
|
||||
@ -46,12 +111,6 @@ 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;
|
||||
@ -66,16 +125,68 @@ 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 { title, slug, text } = this.recipeFormGroup.value;
|
||||
const value = this.recipeFormGroup.value;
|
||||
this.submit.emit({
|
||||
_type: 'submit',
|
||||
data: {
|
||||
title: title!,
|
||||
slug: slug!,
|
||||
rawText: text!,
|
||||
},
|
||||
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!,
|
||||
});
|
||||
}
|
||||
|
||||
protected onDraftDelete(): void {
|
||||
this.deleteDraft.emit();
|
||||
}
|
||||
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
}
|
||||
|
||||
@ -8,9 +8,8 @@ describe('Logo', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Logo]
|
||||
})
|
||||
.compileComponents();
|
||||
imports: [Logo],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Logo);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -77,6 +77,11 @@ 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> {
|
||||
@ -99,6 +104,10 @@ 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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user