Recipe upload integration with backend WIP.
This commit is contained in:
parent
6e6bf04541
commit
ff9ffd9e13
@ -33,7 +33,6 @@
|
|||||||
"@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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,4 +3,5 @@ export const Endpoints = {
|
|||||||
authLogout: 'auth/logout',
|
authLogout: 'auth/logout',
|
||||||
authRefresh: 'auth/refresh',
|
authRefresh: 'auth/refresh',
|
||||||
recipes: 'recipes',
|
recipes: 'recipes',
|
||||||
|
recipeDrafts: 'recipe-drafts',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,13 +31,6 @@ form {
|
|||||||
/* font-size: 16px;*/
|
/* font-size: 16px;*/
|
||||||
/*}*/
|
/*}*/
|
||||||
|
|
||||||
textarea {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: auto;
|
|
||||||
overflow: hidden;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#recipe-form-and-source {
|
#recipe-form-and-source {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@ -7,8 +7,8 @@ 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 { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel';
|
import { RecipeUploadClientModel } from '../../shared/client-models/RecipeUploadClientModel';
|
||||||
import { RecipeUploadService } from '../../shared/services/RecipeUploadService';
|
import { RecipeDraftService } from '../../shared/services/RecipeDraftService';
|
||||||
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';
|
||||||
@ -21,35 +21,33 @@ import { from, map, switchMap, tap } from 'rxjs';
|
|||||||
styleUrl: './recipe-upload-page.css',
|
styleUrl: './recipe-upload-page.css',
|
||||||
})
|
})
|
||||||
export class RecipeUploadPage implements OnInit {
|
export class RecipeUploadPage implements OnInit {
|
||||||
protected readonly model = signal<RecipeUploadModel>({
|
protected readonly model = signal<RecipeUploadClientModel>({
|
||||||
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().sourceFile ?? null);
|
protected readonly sourceFile = computed(() => this.model().inputSourceFile ?? null);
|
||||||
|
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly activatedRoute = inject(ActivatedRoute);
|
private readonly activatedRoute = inject(ActivatedRoute);
|
||||||
private readonly recipeUploadService = inject(RecipeUploadService);
|
private readonly recipeUploadService = inject(RecipeDraftService);
|
||||||
|
|
||||||
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 [draftId, step];
|
return [paramMap.get('draftId'), step] as const;
|
||||||
}),
|
}),
|
||||||
switchMap(([draftId, step]) => {
|
switchMap(([draftId, step]) => {
|
||||||
const currentModel = this.model();
|
const currentModel = this.model();
|
||||||
if (draftId !== null && currentModel.id !== draftId) {
|
if (draftId !== null) {
|
||||||
return this.recipeUploadService.getRecipeUploadModel(draftId).pipe(
|
return this.recipeUploadService.getRecipeUploadClientModel(draftId).pipe(
|
||||||
tap((updatedModel) => {
|
tap((recipeUploadClientModel) => {
|
||||||
this.model.set(updatedModel);
|
this.switchModel(recipeUploadClientModel);
|
||||||
}),
|
}),
|
||||||
switchMap((updatedModel) => {
|
switchMap((updatedModel) => {
|
||||||
if (step !== null && step <= updatedModel.inProgressStep) {
|
if (step !== null && step <= updatedModel.inProgressStep) {
|
||||||
@ -69,13 +67,17 @@ export class RecipeUploadPage implements OnInit {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private switchModel(model: RecipeUploadClientModel): void {
|
||||||
|
this.model.set(model);
|
||||||
|
this.includeInfer.set(!!model.draft?.lastInference);
|
||||||
|
}
|
||||||
|
|
||||||
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: {
|
||||||
step: targetStep,
|
step: targetStep,
|
||||||
draftId: this.model().id,
|
|
||||||
},
|
},
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
});
|
});
|
||||||
@ -89,12 +91,12 @@ 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,
|
||||||
sourceFile: event.file,
|
inputSourceFile: event.file,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
this.model.update((model) => ({
|
this.model.update((model) => ({
|
||||||
...model,
|
...model,
|
||||||
sourceFile: null,
|
inputSourceFile: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,7 +105,7 @@ export class RecipeUploadPage implements OnInit {
|
|||||||
if (event.mode === 'manual') {
|
if (event.mode === 'manual') {
|
||||||
this.model.update((model) => ({
|
this.model.update((model) => ({
|
||||||
...model,
|
...model,
|
||||||
sourceFile: null,
|
inputSourceFile: null,
|
||||||
inProgressStep: RecipeUploadStep.ENTER_DATA,
|
inProgressStep: RecipeUploadStep.ENTER_DATA,
|
||||||
}));
|
}));
|
||||||
await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
||||||
@ -111,7 +113,7 @@ export class RecipeUploadPage implements OnInit {
|
|||||||
} else {
|
} else {
|
||||||
this.model.update((model) => ({
|
this.model.update((model) => ({
|
||||||
...model,
|
...model,
|
||||||
sourceFile: this.sourceFile(),
|
inputSourceFile: this.sourceFile(),
|
||||||
inProgressStep: RecipeUploadStep.INFER,
|
inProgressStep: RecipeUploadStep.INFER,
|
||||||
}));
|
}));
|
||||||
await this.changeDisplayStep(RecipeUploadStep.INFER);
|
await this.changeDisplayStep(RecipeUploadStep.INFER);
|
||||||
|
|||||||
@ -1,5 +1,26 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Start</h2>
|
<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()) {
|
||||||
|
<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>
|
||||||
@ -11,3 +32,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, inject, input, output } from '@angular/core';
|
||||||
import { MatButton } from '@angular/material/button';
|
import { 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],
|
imports: [MatButton, ReactiveFormsModule, FileUpload, FaIconComponent],
|
||||||
templateUrl: './ai-or-manual.html',
|
templateUrl: './ai-or-manual.html',
|
||||||
styleUrl: './ai-or-manual.css',
|
styleUrl: './ai-or-manual.css',
|
||||||
})
|
})
|
||||||
@ -16,6 +22,9 @@ 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) {
|
||||||
@ -25,6 +34,13 @@ export class AiOrManual {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected readonly inProgressDrafts = injectQuery(() => ({
|
||||||
|
queryKey: ['recipe-upload', 'in-progress-drafts'],
|
||||||
|
queryFn: () => {
|
||||||
|
return this.recipeDraftService.getInProgressDrafts();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
protected onFileChange(event: FileUploadEvent) {
|
protected onFileChange(event: FileUploadEvent) {
|
||||||
this.sourceFileChange.emit(event);
|
this.sourceFileChange.emit(event);
|
||||||
}
|
}
|
||||||
@ -32,4 +48,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,3 +3,10 @@ form {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 60ch;
|
width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,11 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Recipe Text</mat-label>
|
<mat-label>Recipe Text</mat-label>
|
||||||
<textarea matInput [formControl]="recipeFormGroup.controls.text"></textarea>
|
<textarea
|
||||||
|
#recipeTextTextarea
|
||||||
|
matInput
|
||||||
|
[formControl]="recipeFormGroup.controls.text"
|
||||||
|
(input)="onRecipeTextChange($event)"
|
||||||
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,5 +1,15 @@
|
|||||||
import { Component, input, OnInit } from '@angular/core';
|
import {
|
||||||
import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel';
|
afterNextRender,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
Injector,
|
||||||
|
input,
|
||||||
|
OnInit,
|
||||||
|
runInInjectionContext,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel';
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||||
|
|
||||||
@ -10,14 +20,25 @@ import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
|||||||
styleUrl: './enter-recipe-data.css',
|
styleUrl: './enter-recipe-data.css',
|
||||||
})
|
})
|
||||||
export class EnterRecipeData implements OnInit {
|
export class EnterRecipeData implements OnInit {
|
||||||
public readonly model = input.required<RecipeUploadModel>();
|
public readonly model = input.required<RecipeUploadClientModel>();
|
||||||
|
|
||||||
|
protected recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
||||||
|
|
||||||
|
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.userTitle ?? model.inferredTitle ?? '',
|
title: model.draft?.title ?? '',
|
||||||
slug: model.userSlug ?? model.inferredSlug ?? '',
|
slug: model.draft?.slug ?? '',
|
||||||
text: model.userText ?? model.inferredText ?? '',
|
text: model.draft?.rawText ?? '',
|
||||||
|
});
|
||||||
|
runInInjectionContext(this.injector, () => {
|
||||||
|
afterNextRender({
|
||||||
|
mixedReadWrite: () => {
|
||||||
|
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,4 +47,18 @@ export class EnterRecipeData implements OnInit {
|
|||||||
slug: new FormControl('', Validators.required),
|
slug: new FormControl('', Validators.required),
|
||||||
text: new FormControl('', Validators.required),
|
text: new FormControl('', Validators.required),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
|
||||||
|
const windowScrollX = window.scrollX;
|
||||||
|
const windowScrollY = window.scrollY;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(windowScrollX, windowScrollY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onRecipeTextChange(event: Event): void {
|
||||||
|
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/app/shared/client-models/RecipeUploadClientModel.ts
Normal file
8
src/app/shared/client-models/RecipeUploadClientModel.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { RecipeUploadStep } from './RecipeUploadStep';
|
||||||
|
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
|
||||||
|
|
||||||
|
export interface RecipeUploadClientModel {
|
||||||
|
inProgressStep: RecipeUploadStep;
|
||||||
|
inputSourceFile?: File | null;
|
||||||
|
draft?: RecipeDraftViewModel;
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface RecipeUploadIngredientModel {
|
|
||||||
amount: string | null;
|
|
||||||
name: string;
|
|
||||||
notes: string | null;
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { RecipeUploadIngredientModel } from './RecipeUploadIngredientModel';
|
|
||||||
import { RecipeUploadStep } from './RecipeUploadStep';
|
|
||||||
|
|
||||||
export interface RecipeUploadModel {
|
|
||||||
inProgressStep: RecipeUploadStep;
|
|
||||||
|
|
||||||
id?: number | null;
|
|
||||||
sourceFile?: File | null;
|
|
||||||
|
|
||||||
inferredText?: string | null;
|
|
||||||
inferredIngredients?: RecipeUploadIngredientModel[] | null;
|
|
||||||
inferredTitle?: string | null;
|
|
||||||
inferredSlug?: string | null;
|
|
||||||
|
|
||||||
userText?: string | null;
|
|
||||||
userIngredients?: RecipeUploadIngredientModel[] | null;
|
|
||||||
userTitle?: string | null;
|
|
||||||
userSlug?: string | null;
|
|
||||||
}
|
|
||||||
@ -1,8 +1,13 @@
|
|||||||
import { CanActivateFn } from '@angular/router';
|
import { CanActivateFn, RedirectCommand, Router } from '@angular/router';
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { AuthService } from '../services/AuthService';
|
import { AuthService } from '../services/AuthService';
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = (route, state) => {
|
export const authGuard: CanActivateFn = (route, state) => {
|
||||||
const authService = inject(AuthService);
|
const authService = inject(AuthService);
|
||||||
return authService.accessToken() !== null;
|
if (authService.accessToken() === null) {
|
||||||
|
const router = inject(Router);
|
||||||
|
return new RedirectCommand(router.parseUrl('/'));
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
31
src/app/shared/models/RecipeDraftView.model.ts
Normal file
31
src/app/shared/models/RecipeDraftView.model.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { ResourceOwner } from './ResourceOwner.model';
|
||||||
|
import { ImageView } from './ImageView.model';
|
||||||
|
|
||||||
|
export interface RecipeDraftViewModel {
|
||||||
|
id: string;
|
||||||
|
created: Date;
|
||||||
|
modified?: Date | null;
|
||||||
|
state: 'INFER' | 'ENTER_DATA';
|
||||||
|
slug?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
preparationTime?: number | null;
|
||||||
|
cookingTime?: number | null;
|
||||||
|
totalTime?: number | null;
|
||||||
|
rawText?: string | null;
|
||||||
|
ingredients?: IngredientDraft[] | null;
|
||||||
|
owner: ResourceOwner;
|
||||||
|
mainImage?: ImageView | null;
|
||||||
|
lastInference?: RecipeDraftInferenceView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngredientDraft {
|
||||||
|
amount?: string | null;
|
||||||
|
name: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeDraftInferenceView {
|
||||||
|
inferredAt: Date;
|
||||||
|
title: string;
|
||||||
|
rawText: string;
|
||||||
|
}
|
||||||
68
src/app/shared/services/RecipeDraftService.ts
Normal file
68
src/app/shared/services/RecipeDraftService.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { delay, firstValueFrom, map, Observable, of } from 'rxjs';
|
||||||
|
import { RecipeUploadClientModel } from '../client-models/RecipeUploadClientModel';
|
||||||
|
import { RecipeUploadStep } from '../client-models/RecipeUploadStep';
|
||||||
|
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
|
||||||
|
import { EndpointService } from './EndpointService';
|
||||||
|
import { WithStringDates } from '../util';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class RecipeDraftService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly endpointService = inject(EndpointService);
|
||||||
|
|
||||||
|
private hydrateView(rawView: WithStringDates<RecipeDraftViewModel>): 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 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { delay, Observable, of } from 'rxjs';
|
|
||||||
import { RecipeUploadModel } from '../client-models/RecipeUploadModel';
|
|
||||||
import { RecipeUploadStep } from '../client-models/RecipeUploadStep';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class RecipeUploadService {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
|
|
||||||
public getRecipeUploadModel(draftId: number): Observable<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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,3 +21,15 @@ 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];
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user