Compare commits
No commits in common. "main" and "with-ng-mat" have entirely different histories.
main
...
with-ng-ma
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,16 +31,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (mainImageUrl.isSuccess()) {
|
@if (mainImageUrl.isSuccess()) {
|
||||||
@let maybeMainImageUrl = mainImageUrl.data();
|
<img
|
||||||
@if (!!maybeMainImageUrl) {
|
id="main-image"
|
||||||
<img
|
[src]="mainImageUrl.data()"
|
||||||
id="main-image"
|
[alt]="recipe.mainImage.alt"
|
||||||
[src]="maybeMainImageUrl"
|
[height]="recipe.mainImage.height"
|
||||||
[alt]="recipe.mainImage!.alt"
|
[width]="recipe.mainImage.width"
|
||||||
[height]="recipe.mainImage!.height"
|
/>
|
||||||
[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" />
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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%;
|
||||||
|
}
|
||||||
@ -1,25 +1,65 @@
|
|||||||
<h1>Upload Recipe</h1>
|
<div id="recipe-upload-container">
|
||||||
<app-recipe-upload-trail
|
<h1>Upload Recipe</h1>
|
||||||
[displayStep]="displayStep()"
|
<app-recipe-upload-trail
|
||||||
[inProgressStep]="inProgressStep()"
|
[displayStep]="displayStep()"
|
||||||
[includeInfer]="includeInfer()"
|
[inProgressStep]="inProgressStep()"
|
||||||
(stepClick)="onStepClick($event)"
|
[includeInfer]="includeInfer()"
|
||||||
></app-recipe-upload-trail>
|
(stepClick)="onStepClick($event)"
|
||||||
|
></app-recipe-upload-trail>
|
||||||
|
|
||||||
@if (displayStep() === RecipeUploadStep.START) {
|
@if (displayStep() === RecipeUploadStep.START) {
|
||||||
<app-ai-or-manual
|
<app-ai-or-manual
|
||||||
[sourceFile]="sourceFile()"
|
[sourceFile]="sourceFile()"
|
||||||
(sourceFileChange)="onSourceFileChange($event)"
|
(sourceFileChange)="onSourceFileChange($event)"
|
||||||
(submitStep)="onAiOrManualSubmit($event)"
|
(submitStep)="onAiOrManualSubmit($event)"
|
||||||
></app-ai-or-manual>
|
></app-ai-or-manual>
|
||||||
} @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>
|
<section>
|
||||||
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
|
<h2>Auto-Complete Recipe (Optional)</h2>
|
||||||
<app-review [draft]="model().draft!" (publish)="onPublish()"></app-review>
|
<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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -1,35 +1,11 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Start</h2>
|
<h2>Start</h2>
|
||||||
<section>
|
<p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p>
|
||||||
@if (inProgressDrafts.isLoading()) {
|
<form id="ai-or-manual-form">
|
||||||
<p>Loading drafts...</p>
|
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
|
||||||
} @else if (inProgressDrafts.isError()) {
|
<div id="ai-or-manual-buttons">
|
||||||
<p>Could not fetch drafts!</p>
|
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
|
||||||
} @else if (inProgressDrafts.isSuccess() && inProgressDrafts.data().length) {
|
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">Use AI Assist</button>
|
||||||
<h3>In Progress Drafts</h3>
|
</div>
|
||||||
<ul>
|
</form>
|
||||||
@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>
|
|
||||||
<form id="ai-or-manual-form">
|
|
||||||
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
|
|
||||||
<div id="ai-or-manual-buttons">
|
|
||||||
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
|
|
||||||
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">
|
|
||||||
Use AI Assist
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
export interface EnterRecipeDataSubmitEvent {
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
ingredients: Array<{
|
|
||||||
amount: string | null;
|
|
||||||
name: string;
|
|
||||||
notes: string | null;
|
|
||||||
}>;
|
|
||||||
rawText: string;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { RecipeUploadStep } from './RecipeUploadStep';
|
|
||||||
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
|
|
||||||
|
|
||||||
export interface RecipeUploadClientModel {
|
|
||||||
inProgressStep: RecipeUploadStep;
|
|
||||||
inputSourceFile?: File | null;
|
|
||||||
draft?: RecipeDraftViewModel;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export interface RecipeUploadIngredientModel {
|
||||||
|
amount: string | null;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
19
src/app/shared/client-models/RecipeUploadModel.ts
Normal file
19
src/app/shared/client-models/RecipeUploadModel.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,5 +2,4 @@ export enum RecipeUploadStep {
|
|||||||
START,
|
START,
|
||||||
INFER,
|
INFER,
|
||||||
ENTER_DATA,
|
ENTER_DATA,
|
||||||
REVIEW,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<div>
|
|
||||||
<fa-icon [icon]="faUtensils" size="2x" class="utensils"></fa-icon>
|
|
||||||
</div>
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface NavLinkConfig {
|
|
||||||
relativeUrl: string;
|
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,17 +8,13 @@ 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, {
|
responseType: 'blob',
|
||||||
responseType: 'blob',
|
})
|
||||||
})
|
.pipe(map((blob) => URL.createObjectURL(blob))),
|
||||||
.pipe(map((blob) => URL.createObjectURL(blob))),
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
31
src/app/shared/services/RecipeUploadService.ts
Normal file
31
src/app/shared/services/RecipeUploadService.ts
Normal 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
export const environment = {
|
|
||||||
apiBaseUrl: 'http://localhost:8080',
|
|
||||||
};
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export const environment = {
|
|
||||||
apiBaseUrl: 'http://localhost:8080',
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue
Block a user