Compare commits

..

No commits in common. "main" and "with-ng-mat" have entirely different histories.

49 changed files with 355 additions and 869 deletions

View File

@ -46,13 +46,7 @@
"development": { "development": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true, "sourceMap": true
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"

View File

@ -33,6 +33,7 @@
"@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@tanstack/angular-query-experimental": "^5.90.16", "@tanstack/angular-query-experimental": "^5.90.16",
"ngx-sse-client": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },

View File

@ -3,7 +3,6 @@ import { RecipePage } from './pages/recipe-page/recipe-page';
import { RecipesPage } from './pages/recipes-page/recipes-page'; import { RecipesPage } from './pages/recipes-page/recipes-page';
import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page'; import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page';
import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page'; import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page';
import { authGuard } from './shared/guards/auth-guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -17,7 +16,6 @@ export const routes: Routes = [
{ {
path: 'recipe-upload', path: 'recipe-upload',
component: RecipeUploadPage, component: RecipeUploadPage,
canActivate: [authGuard],
}, },
{ {
path: 'recipes/:username/:slug', path: 'recipes/:username/:slug',

View File

@ -1,7 +1,3 @@
export const Endpoints = { export const Endpoints = {
authLogin: 'auth/login',
authLogout: 'auth/logout',
authRefresh: 'auth/refresh',
recipes: 'recipes', recipes: 'recipes',
recipeDrafts: 'recipe-drafts',
}; };

View File

@ -31,17 +31,14 @@
</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]="maybeMainImageUrl" [src]="mainImageUrl.data()"
[alt]="recipe.mainImage!.alt" [alt]="recipe.mainImage.alt"
[height]="recipe.mainImage!.height" [height]="recipe.mainImage.height"
[width]="recipe.mainImage!.width" [width]="recipe.mainImage.width"
/> />
} }
}
<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" />
</article> </article>

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: ['recipe-main-images', recipe.owner.username, recipe.slug], queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(recipe.mainImage?.url), queryFn: () => this.imageService.getImage(recipe.mainImage.url),
}; };
}); });

View File

@ -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%;
}

View File

@ -1,3 +1,4 @@
<div id="recipe-upload-container">
<h1>Upload Recipe</h1> <h1>Upload Recipe</h1>
<app-recipe-upload-trail <app-recipe-upload-trail
[displayStep]="displayStep()" [displayStep]="displayStep()"
@ -15,11 +16,50 @@
} @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 <app-enter-recipe-data [model]="model()"></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>

View File

@ -7,68 +7,59 @@ import { EnterRecipeData } from './steps/enter-recipe-data/enter-recipe-data';
import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail'; import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { StepClickEvent } from './recipe-upload-trail/StepClickEvent'; import { StepClickEvent } from './recipe-upload-trail/StepClickEvent';
import { RecipeUploadClientModel } from '../../shared/client-models/RecipeUploadClientModel'; import { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel';
import { RecipeDraftService } from '../../shared/services/RecipeDraftService'; import { RecipeUploadService } from '../../shared/services/RecipeUploadService';
import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep'; 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 { Review } from './steps/review/review';
import { EnterRecipeDataSubmitEvent } from './steps/enter-recipe-data/EnterRecipeDataSubmitEvent';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Component({ @Component({
selector: 'app-recipe-upload-page', selector: 'app-recipe-upload-page',
imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail, Review], imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail],
templateUrl: './recipe-upload-page.html', templateUrl: './recipe-upload-page.html',
styleUrl: './recipe-upload-page.css', styleUrl: './recipe-upload-page.css',
}) })
export class RecipeUploadPage implements OnInit { export class RecipeUploadPage implements OnInit {
protected readonly model = signal<RecipeUploadClientModel>({ protected readonly model = signal<RecipeUploadModel>({
inProgressStep: RecipeUploadStep.START, inProgressStep: RecipeUploadStep.START,
}); });
protected readonly displayStep = signal<number>(RecipeUploadStep.START); protected readonly displayStep = signal<number>(RecipeUploadStep.START);
protected readonly inProgressStep = computed(() => this.model().inProgressStep); protected readonly inProgressStep = computed(() => this.model().inProgressStep);
protected readonly includeInfer = signal(false); protected readonly includeInfer = signal(false);
protected readonly sourceFile = computed(() => this.model().inputSourceFile ?? null); protected readonly sourceFile = computed(() => this.model().sourceFile ?? null);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly activatedRoute = inject(ActivatedRoute); private readonly activatedRoute = inject(ActivatedRoute);
private readonly recipeDraftService = inject(RecipeDraftService); private readonly recipeUploadService = inject(RecipeUploadService);
private readonly queryClient = inject(QueryClient);
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
.pipe( .pipe(
map((paramMap) => { map((paramMap) => {
const draftIdParam: string | null = paramMap.get('draftId');
const draftId = tryMaybeInt(draftIdParam);
const stepParam: string | null = paramMap.get('step'); const stepParam: string | null = paramMap.get('step');
const step = tryMaybeInt(stepParam); const step = tryMaybeInt(stepParam);
return [paramMap.get('draftId'), step] as const; return [draftId, step];
}), }),
switchMap(([draftId, step]) => { switchMap(([draftId, step]) => {
if (draftId !== null) { const currentModel = this.model();
return this.recipeDraftService.getRecipeUploadClientModel(draftId).pipe( if (draftId !== null && currentModel.id !== draftId) {
tap(async (recipeUploadClientModel) => { return this.recipeUploadService.getRecipeUploadModel(draftId).pipe(
await this.switchModel(recipeUploadClientModel); tap((updatedModel) => {
this.model.set(updatedModel);
}), }),
switchMap((updatedModel) => { switchMap((updatedModel) => {
if (step !== null && this.isValidStep(step)) { if (step !== null && step <= updatedModel.inProgressStep) {
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 && this.isValidStep(step)) { } else if (step !== null && step <= currentModel.inProgressStep) {
return from(this.changeDisplayStep(step)); return from(this.changeDisplayStep(step));
} else { } else {
return from(this.changeDisplayStep(RecipeUploadStep.START)); return from(this.changeDisplayStep(RecipeUploadStep.START));
@ -78,28 +69,15 @@ export class RecipeUploadPage implements OnInit {
.subscribe(); .subscribe();
} }
private async switchModel(
model: RecipeUploadClientModel,
switchStep: boolean | RecipeUploadStep = false,
): Promise<void> {
this.model.set(model);
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> {
this.displayStep.set(targetStep); this.displayStep.set(targetStep);
await this.router.navigate([], { await this.router.navigate([], {
relativeTo: this.activatedRoute, relativeTo: this.activatedRoute,
queryParams: { queryParams: {
draftId: this.model().draft?.id,
step: targetStep, step: targetStep,
draftId: this.model().id,
}, },
queryParamsHandling: 'replace', queryParamsHandling: 'merge',
}); });
} }
@ -111,57 +89,124 @@ export class RecipeUploadPage implements OnInit {
if (event._tag === 'file-add-event') { if (event._tag === 'file-add-event') {
this.model.update((model) => ({ this.model.update((model) => ({
...model, ...model,
inputSourceFile: event.file, sourceFile: event.file,
})); }));
} else { } else {
this.model.update((model) => ({ this.model.update((model) => ({
...model, ...model,
inputSourceFile: null, sourceFile: null,
})); }));
} }
} }
protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> { protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> {
if (event.mode === 'manual') { if (event.mode === 'manual') {
const model = await this.recipeDraftService.createManualDraft(); this.model.update((model) => ({
await this.switchModel(model, true); ...model,
sourceFile: 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,
inputSourceFile: this.sourceFile(), sourceFile: this.sourceFile(),
inProgressStep: RecipeUploadStep.INFER, inProgressStep: RecipeUploadStep.INFER,
})); }));
await this.changeDisplayStep(RecipeUploadStep.INFER); await this.changeDisplayStep(RecipeUploadStep.INFER);
this.includeInfer.set(true); this.includeInfer.set(true);
this.recipeDraftService.doInference(this.model()).subscribe((updatedModel) => { this.recipeUploadService.doInference(this.model()).subscribe((updatedModel) => {
this.model.set(updatedModel); this.model.set(updatedModel);
this.changeDisplayStep(RecipeUploadStep.ENTER_DATA); this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
}); });
} }
} }
protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise<void> { // private readonly sseClient = inject(SseClient);
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event); // private readonly formBuilder = inject(FormBuilder);
await this.switchModel(model, RecipeUploadStep.REVIEW); //
} // protected readonly sourceRecipeImage = signal<string | null>(null);
// protected readonly inferenceInProgress = signal(false);
protected async onDeleteDraft(): Promise<void> { //
await this.recipeDraftService.deleteDraft(this.model().draft!.id); // protected readonly recipeUploadForm = this.formBuilder.group({
await this.queryClient.invalidateQueries({ // file: this.formBuilder.control<File | null>(null, [Validators.required]),
queryKey: ['recipe-upload', 'in-progress-drafts'], // });
}); //
await this.switchModel( // protected readonly recipeForm = new FormGroup({
{ // title: new FormControl('', [Validators.required]),
inProgressStep: RecipeUploadStep.START, // recipeText: new FormControl('', Validators.required),
}, // });
RecipeUploadStep.START, //
); // protected onClear() {
} // this.recipeUploadForm.reset();
// this.sourceRecipeImage.set(null);
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 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

@ -5,7 +5,7 @@
'step-complete': step.completed, 'step-complete': step.completed,
'step-in-progress': step.inProgress, 'step-in-progress': step.inProgress,
'step-incomplete': !step.completed, 'step-incomplete': !step.completed,
'step-displayed': displayStep() === step.index, 'step-displayed': displayStep() === step.index
}" }"
> >
@if (step.completed || step.inProgress) { @if (step.completed || step.inProgress) {

View File

@ -34,12 +34,6 @@ export class RecipeUploadTrail {
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA, completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
inProgress: 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()) { if (this.includeInfer()) {
base.push({ base.push({

View File

@ -1,35 +1,11 @@
<section> <section>
<h2>Start</h2> <h2>Start</h2>
<section>
@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>
<ul>
@for (draft of inProgressDrafts.data(); track draft.id) {
<li>
<a (click)="onInProgressDraftClick(draft)">
<fa-icon [icon]="faFilePen"></fa-icon>
{{ draft.title }}
</a>
</li>
}
</ul>
}
</section>
<section>
<h3>New Draft</h3>
<p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p> <p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p>
<form id="ai-or-manual-form"> <form id="ai-or-manual-form">
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload> <app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
<div id="ai-or-manual-buttons"> <div id="ai-or-manual-buttons">
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button> <button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')"> <button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">Use AI Assist</button>
Use AI Assist
</button>
</div> </div>
</form> </form>
</section> </section>
</section>

View File

@ -1,19 +1,13 @@
import { Component, computed, inject, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent'; import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent';
import { FileUpload } from '../../../../shared/components/file-upload/file-upload'; import { FileUpload } from '../../../../shared/components/file-upload/file-upload';
import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent'; 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({ @Component({
selector: 'app-ai-or-manual', selector: 'app-ai-or-manual',
imports: [MatButton, ReactiveFormsModule, FileUpload, FaIconComponent], imports: [MatButton, ReactiveFormsModule, FileUpload],
templateUrl: './ai-or-manual.html', templateUrl: './ai-or-manual.html',
styleUrl: './ai-or-manual.css', styleUrl: './ai-or-manual.css',
}) })
@ -22,9 +16,6 @@ export class AiOrManual {
public sourceFileChange = output<FileUploadEvent>(); public sourceFileChange = output<FileUploadEvent>();
public submitStep = output<AIOrManualSubmitEvent>(); public submitStep = output<AIOrManualSubmitEvent>();
private readonly recipeDraftService = inject(RecipeDraftService);
private readonly router = inject(Router);
protected readonly sourceFilesArray = computed(() => { protected readonly sourceFilesArray = computed(() => {
const maybeSourceFile = this.sourceFile(); const maybeSourceFile = this.sourceFile();
if (maybeSourceFile) { if (maybeSourceFile) {
@ -34,13 +25,6 @@ export class AiOrManual {
} }
}); });
protected readonly inProgressDrafts = injectQuery(() => ({
queryKey: ['recipe-upload', 'in-progress-drafts'],
queryFn: () => {
return this.recipeDraftService.getInProgressDrafts();
},
}));
protected onFileChange(event: FileUploadEvent) { protected onFileChange(event: FileUploadEvent) {
this.sourceFileChange.emit(event); this.sourceFileChange.emit(event);
} }
@ -48,14 +32,4 @@ export class AiOrManual {
protected onFormSubmit(mode: 'manual' | 'ai-assist') { protected onFormSubmit(mode: 'manual' | 'ai-assist') {
this.submitStep.emit({ mode }); this.submitStep.emit({ mode });
} }
protected async onInProgressDraftClick(draft: RecipeDraftViewModel) {
await this.router.navigate(['/recipe-upload'], {
queryParams: {
draftId: draft.id,
},
});
}
protected readonly faFilePen = faFilePen;
} }

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,25 +1,5 @@
.ingredients-table {
width: 100ch;
}
.ingredient-input {
width: 100%;
}
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 60ch; width: 60ch;
} }
textarea {
box-sizing: border-box;
height: auto;
overflow: hidden;
resize: none;
}
.draft-info-container {
display: flex;
column-gap: 10px;
}

View File

@ -1,93 +1,15 @@
<h2>Enter Recipe</h2> <h2>Enter Recipe</h2>
<div class="draft-info-container"> <form [formGroup]="recipeFormGroup">
<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-form-field>
<mat-label>Title</mat-label> <mat-label>Title</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.title" /> <input matInput [formControl]="recipeFormGroup.controls.title">
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label>Slug</mat-label> <mat-label>Slug</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.slug" /> <input matInput [formControl]="recipeFormGroup.controls.slug">
</mat-form-field> </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-form-field>
<mat-label>Recipe Text</mat-label> <mat-label>Recipe Text</mat-label>
<textarea <textarea matInput [formControl]="recipeFormGroup.controls.text"></textarea>
#recipeTextTextarea
matInput
[formControl]="recipeFormGroup.controls.text"
(input)="onRecipeTextChange($event)"
></textarea>
</mat-form-field> </mat-form-field>
<button matButton="filled" type="submit">Review</button>
</form> </form>

View File

@ -1,192 +1,29 @@
import { import { Component, input, OnInit } from '@angular/core';
afterNextRender, import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel';
Component, import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
ElementRef,
inject,
Injector,
input,
OnInit,
output,
runInInjectionContext,
viewChild,
viewChildren,
} from '@angular/core';
import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel';
import { FormArray, 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 { 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({ @Component({
selector: 'app-enter-recipe-data', selector: 'app-enter-recipe-data',
imports: [ imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput],
ReactiveFormsModule,
MatFormField,
MatLabel,
MatInput,
MatButton,
MatTable,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatCell,
MatCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
FaIconComponent,
MatMenuTrigger,
MatMenu,
MatMenuItem,
],
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<RecipeUploadModel>();
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);
public ngOnInit(): void { public ngOnInit(): void {
const model = this.model(); const model = this.model();
this.recipeFormGroup.patchValue({ this.recipeFormGroup.patchValue({
title: model.draft?.title ?? '', title: model.userTitle ?? model.inferredTitle ?? '',
slug: model.draft?.slug ?? '', slug: model.userSlug ?? model.inferredSlug ?? '',
text: model.draft?.rawText ?? '', text: model.userText ?? model.inferredText ?? '',
ingredients: model.draft?.ingredients ?? [],
});
runInInjectionContext(this.injector, () => {
afterNextRender({
mixedReadWrite: () => {
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
},
});
}); });
} }
private updateTextareaHeight(textarea: HTMLTextAreaElement) { protected readonly recipeFormGroup = new FormGroup({
const windowScrollX = window.scrollX; title: new FormControl('', Validators.required),
const windowScrollY = window.scrollY; slug: new FormControl('', Validators.required),
textarea.style.height = 'auto'; text: new FormControl('', Validators.required),
textarea.style.height = textarea.scrollHeight + 'px';
requestAnimationFrame(() => {
window.scrollTo(windowScrollX, windowScrollY);
}); });
} }
protected onRecipeTextChange(event: Event): void {
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;
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!,
});
}
protected onDraftDelete(): void {
this.deleteDraft.emit();
}
protected readonly faEllipsis = faEllipsis;
}

View File

@ -1,9 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,8 +0,0 @@
import { RecipeUploadStep } from './RecipeUploadStep';
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
export interface RecipeUploadClientModel {
inProgressStep: RecipeUploadStep;
inputSourceFile?: File | null;
draft?: RecipeDraftViewModel;
}

View File

@ -0,0 +1,5 @@
export interface RecipeUploadIngredientModel {
amount: string | null;
name: string;
notes: string | null;
}

View File

@ -0,0 +1,19 @@
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;
}

View File

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

View File

@ -1,13 +0,0 @@
div {
width: 60px;
height: 60px;
border-radius: 10px;
background-color: var(--primary-red);
display: flex;
align-items: center;
justify-content: center;
}
.utensils {
color: var(--primary-white);
}

View File

@ -1,3 +0,0 @@
<div>
<fa-icon [icon]="faUtensils" size="2x" class="utensils"></fa-icon>
</div>

View File

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

View File

@ -1,13 +0,0 @@
import { Component } from '@angular/core';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faUtensils } from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'app-logo',
imports: [FaIconComponent],
templateUrl: './logo.html',
styleUrl: './logo.css',
})
export class Logo {
protected readonly faUtensils = faUtensils;
}

View File

@ -1,5 +0,0 @@
export interface NavLinkConfig {
relativeUrl: string;
title: string;
disabled?: boolean;
}

View File

@ -1,12 +1,8 @@
<nav> <nav>
<h2>Nav</h2> <h2>Nav</h2>
<ul> <ul>
@for (link of links(); track link.relativeUrl) { <li><a [routerLink]="'/'">Browse Recipes</a></li>
@if (!link.disabled) { <li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
<li> <li><a [routerLink]="'/recipe-upload'">Upload Recipe</a></li>
<a [routerLink]="link.relativeUrl">{{ link.title }}</a>
</li>
}
}
</ul> </ul>
</nav> </nav>

View File

@ -1,7 +1,5 @@
import { Component, computed, inject } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AuthService } from '../../services/AuthService';
import { NavLinkConfig } from './NavLinkConfig';
@Component({ @Component({
selector: 'app-nav', selector: 'app-nav',
@ -9,24 +7,4 @@ import { NavLinkConfig } from './NavLinkConfig';
templateUrl: './nav.html', templateUrl: './nav.html',
styleUrl: './nav.css', styleUrl: './nav.css',
}) })
export class Nav { export class Nav {}
private readonly authService = inject(AuthService);
protected readonly links = computed<NavLinkConfig[]>(() => {
return [
{
relativeUrl: '/',
title: 'Browse Recipes',
},
{
relativeUrl: '/recipes-search',
title: 'Search Recipes',
},
{
relativeUrl: '/recipe-upload',
title: 'Upload Recipe',
disabled: this.authService.accessToken() === null,
},
];
});
}

View File

@ -4,15 +4,6 @@
object-fit: cover; object-fit: cover;
} }
.recipe-card-image-placeholder {
height: 200px;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
article { article {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

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

View File

@ -5,11 +5,10 @@ import { injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../services/ImageService'; import { ImageService } from '../../../services/ImageService';
import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Logo } from '../../logo/logo';
@Component({ @Component({
selector: 'app-recipe-card', selector: 'app-recipe-card',
imports: [RouterLink, FaIconComponent, Logo], imports: [RouterLink, FaIconComponent],
templateUrl: './recipe-card.html', templateUrl: './recipe-card.html',
styleUrl: './recipe-card.css', styleUrl: './recipe-card.css',
}) })
@ -31,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: ['recipe-main-images', recipe.owner.username, recipe.slug], queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(recipe.mainImage?.url), queryFn: () => this.imageService.getImage(recipe.mainImage.url),
}; };
}); });
} }

View File

@ -26,7 +26,7 @@
<div>{{ addCommentMutation.variables() }}</div> <div>{{ addCommentMutation.variables() }}</div>
</li> </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) { @for (recipeComment of recipeComments.content; track recipeComment.id) {
<li class="comment"> <li class="comment">
<div class="comment-username-time"> <div class="comment-username-time">

View File

@ -1,17 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth-guard';
describe('authGuardGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@ -1,13 +0,0 @@
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);
if (authService.accessToken() === null) {
const router = inject(Router);
return new RedirectCommand(router.parseUrl('/'));
} else {
return true;
}
};

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 | null; mainImage: ImageView;
owner: ResourceOwner; owner: ResourceOwner;
slug: string; slug: string;
starCount: number; starCount: number;

View File

@ -1,31 +0,0 @@
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;
}

View File

@ -4,7 +4,6 @@ import { LoginView } from '../models/LoginView.model';
import { firstValueFrom, Observable, tap } from 'rxjs'; import { firstValueFrom, Observable, tap } from 'rxjs';
import { QueryClient } from '@tanstack/angular-query-experimental'; import { QueryClient } from '@tanstack/angular-query-experimental';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { EndpointService } from './EndpointService';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -19,12 +18,11 @@ export class AuthService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly queryClient = inject(QueryClient); private readonly queryClient = inject(QueryClient);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly endpointService = inject(EndpointService);
public async login(username: string, password: string): Promise<LoginView> { public async login(username: string, password: string): Promise<LoginView> {
const loginView = await firstValueFrom( const loginView = await firstValueFrom(
this.http.post<LoginView>( this.http.post<LoginView>(
this.endpointService.getUrl('authLogin'), 'http://localhost:8080/auth/login',
{ username, password }, { username, password },
{ {
withCredentials: true, withCredentials: true,
@ -38,7 +36,7 @@ export class AuthService {
} }
public async logout(): Promise<void> { public async logout(): Promise<void> {
await firstValueFrom(this.http.post(this.endpointService.getUrl('authLogout'), null)); await firstValueFrom(this.http.post('http://localhost:8080/auth/logout', null));
this._username.set(null); this._username.set(null);
this._accessToken.set(null); this._accessToken.set(null);
await this.router.navigate(['/']); await this.router.navigate(['/']);
@ -49,7 +47,7 @@ export class AuthService {
this._accessToken.set(null); this._accessToken.set(null);
this._username.set(null); this._username.set(null);
return this.http return this.http
.post<LoginView>(this.endpointService.getUrl('authRefresh'), null, { .post<LoginView>('http://localhost:8080/auth/refresh', null, {
withCredentials: true, withCredentials: true,
}) })
.pipe( .pipe(

View File

@ -1,13 +1,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Endpoints } from '../../endpoints'; import { Endpoints } from '../../endpoints';
import { QueryParams } from '../models/Query.model'; import { QueryParams } from '../models/Query.model';
import { environment } from '../../../environments/environment';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class EndpointService { export class EndpointService {
public getUrl(endpoint: keyof typeof Endpoints, pathParts?: string[], queryParams?: QueryParams): string { public getUrl(endpoint: keyof typeof Endpoints, pathParams?: string[], queryParams?: QueryParams): string {
const urlSearchParams = new URLSearchParams(); const urlSearchParams = new URLSearchParams();
if (queryParams?.page !== undefined) { if (queryParams?.page !== undefined) {
urlSearchParams.set('page', queryParams.page.toString()); urlSearchParams.set('page', queryParams.page.toString());
@ -30,7 +29,7 @@ export class EndpointService {
} }
}); });
let pathString = pathParts?.join('/') || ''; let pathString = pathParams?.join('/') || '';
if (pathString?.length) { if (pathString?.length) {
pathString = '/' + pathString; pathString = '/' + pathString;
} }
@ -40,6 +39,6 @@ export class EndpointService {
queryString = '?' + queryString; queryString = '?' + queryString;
} }
return environment.apiBaseUrl + '/' + Endpoints[endpoint] + pathString + queryString; return `http://localhost:8080/${Endpoints[endpoint]}${pathString}${queryString}`;
} }
} }

View File

@ -8,8 +8,7 @@ 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 | null): Promise<string | null> { public getImage(backendUrl: string): Promise<string> {
if (!!backendUrl) {
return firstValueFrom( return firstValueFrom(
this.httpClient this.httpClient
.get(backendUrl, { .get(backendUrl, {
@ -17,8 +16,5 @@ export class ImageService {
}) })
.pipe(map((blob) => URL.createObjectURL(blob))), .pipe(map((blob) => URL.createObjectURL(blob))),
); );
} else {
return Promise.resolve(null);
}
} }
} }

View File

@ -1,121 +0,0 @@
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';
import { Recipe } from '../models/Recipe.model';
@Injectable({
providedIn: 'root',
})
export class RecipeDraftService {
private readonly http = inject(HttpClient);
private readonly endpointService = inject(EndpointService);
private hydrateView(rawView: WithStringDates<RecipeDraftViewModel>): 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<RecipeDraftViewModel[]> {
return firstValueFrom(
this.http.get<WithStringDates<RecipeDraftViewModel>[]>(this.endpointService.getUrl('recipeDrafts')).pipe(
map((rawViews) => {
return rawViews.map((rawView) => this.hydrateView(rawView));
}),
),
);
}
public getRecipeUploadClientModel(draftId: string): Observable<RecipeUploadClientModel> {
return this.http
.get<WithStringDates<RecipeDraftViewModel>>(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 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;
ingredients?: Array<{
amount?: string | null;
name: string;
notes?: 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 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,
id: 16,
inferredTitle: 'Some recipe',
inferredSlug: 'some-recipe',
inferredText: 'Some text.',
inferredIngredients: [],
}).pipe(delay(5_000));
}
}

View File

@ -19,24 +19,24 @@ export class RecipeService {
public getRecipes(): Promise<Recipe[]> { public getRecipes(): Promise<Recipe[]> {
return firstValueFrom( return firstValueFrom(
this.http.get<RecipeInfoViews>(this.endpointService.getUrl('recipes')).pipe(map((res) => res.content)), this.http.get<RecipeInfoViews>('http://localhost:8080/recipes').pipe(map((res) => res.content)),
); );
} }
public getRecipeView(username: string, slug: string): Promise<RecipeView> { public getRecipeView(username: string, slug: string): Promise<RecipeView> {
return firstValueFrom(this.http.get<RecipeView>(this.endpointService.getUrl('recipes', [username, slug]))); return firstValueFrom(this.http.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`));
} }
private getRecipeBaseUrl(recipeView: RecipeView): string { private getRecipeUrl(recipeView: RecipeView): string {
return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]); return `http://localhost:8080/recipes/${recipeView.recipe.owner.username}/${recipeView.recipe.slug}`;
} }
public async toggleStar(recipeView: RecipeView): Promise<void> { public async toggleStar(recipeView: RecipeView): Promise<void> {
if (this.authService.accessToken()) { if (this.authService.accessToken()) {
if (recipeView.isStarred) { if (recipeView.isStarred) {
await lastValueFrom(this.http.delete(this.getRecipeBaseUrl(recipeView) + '/star')); await lastValueFrom(this.http.delete(this.getRecipeUrl(recipeView) + '/star'));
} else { } else {
await lastValueFrom(this.http.post(this.getRecipeBaseUrl(recipeView) + '/star', null)); await lastValueFrom(this.http.post(this.getRecipeUrl(recipeView) + '/star', null));
} }
await this.queryClient.invalidateQueries({ await this.queryClient.invalidateQueries({
queryKey: ['recipe', recipeView.recipe.owner.username, recipeView.recipe.slug], queryKey: ['recipe', recipeView.recipe.owner.username, recipeView.recipe.slug],
@ -56,7 +56,7 @@ export class RecipeService {
public async addComment(username: string, slug: string, commentText: string): Promise<RecipeComment> { public async addComment(username: string, slug: string, commentText: string): Promise<RecipeComment> {
const comment = await firstValueFrom( const comment = await firstValueFrom(
this.http.post<RecipeComment>(this.endpointService.getUrl('recipes', [username, slug, 'comments']), { this.http.post<RecipeComment>(`http://localhost:8080/recipes/${username}/${slug}/comments`, {
text: commentText, text: commentText,
}), }),
); );

View File

@ -0,0 +1,31 @@
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<RecipeUploadModel> {
return of({
inProgressStep: RecipeUploadStep.ENTER_DATA,
id: 42
});
}
public doInference(model: RecipeUploadModel): Observable<RecipeUploadModel> {
return of({
inProgressStep: RecipeUploadStep.ENTER_DATA,
id: 16,
inferredTitle: 'Some recipe',
inferredSlug: 'some-recipe',
inferredText: 'Some text.',
inferredIngredients: []
}).pipe(delay(5_000));
}
}

View File

@ -21,15 +21,3 @@ export const tryMaybeInt = (maybeString: string | null): number | null => {
export const hasValue = <T>(value?: T | null): value is T => { export const hasValue = <T>(value?: T | null): value is T => {
return value !== undefined && value !== null; return value !== undefined && value !== null;
}; };
export type WithStringDates<T> = {
[K in keyof T]: T[K] extends Date
? string
: T[K] extends Date | null | undefined
? string | null | undefined
: T[K] extends Record<string, any>
? WithStringDates<T[K]>
: T[K] extends Record<string, any> | null | undefined
? WithStringDates<T[K]> | null | undefined
: T[K];
};

View File

@ -1,3 +0,0 @@
export const environment = {
apiBaseUrl: 'http://localhost:8080',
};

View File

@ -1,3 +0,0 @@
export const environment = {
apiBaseUrl: 'http://localhost:8080',
};