Compare commits
No commits in common. "main" and "with-ng-mat" have entirely different histories.
main
...
with-ng-ma
10
angular.json
10
angular.json
@ -25,7 +25,7 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/material-theme.scss", "src/styles.css", "src/style-imports.scss"]
|
||||
"styles": ["src/material-theme.scss", "src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -46,13 +46,7 @@
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.development.ts"
|
||||
}
|
||||
]
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
|
||||
57
package-lock.json
generated
57
package-lock.json
generated
@ -18,7 +18,8 @@
|
||||
"@angular/router": "^21.0.0",
|
||||
"@fortawesome/angular-fontawesome": "^4.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"ngx-toastr": "^20.0.5",
|
||||
"@tanstack/angular-query-experimental": "^5.90.16",
|
||||
"ngx-sse-client": "^20.0.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -3976,6 +3977,47 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tanstack/angular-query-experimental": {
|
||||
"version": "5.90.16",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/angular-query-experimental/-/angular-query-experimental-5.90.16.tgz",
|
||||
"integrity": "sha512-ezXyxuaSA6kpwUxwrxo5Tf3B7KL7mb7cxhLnun/RMlw6h3ZQJzl1cJgrvN97+C0AeHoucmK7RwhEoIY12gY7WA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tanstack/query-devtools": "5.91.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
|
||||
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/canonical-json": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
||||
@ -6731,18 +6773,17 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-toastr": {
|
||||
"version": "20.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-20.0.5.tgz",
|
||||
"integrity": "sha512-JcGu1Cbl+0SovPhxma72ygGeZHtpHWWKwBCyiabb+MSWYtXu/SOwEZ2HTWtZ4wcEYOOiy9tDQZgiEKWXpibpRw==",
|
||||
"node_modules/ngx-sse-client": {
|
||||
"version": "20.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ngx-sse-client/-/ngx-sse-client-20.0.1.tgz",
|
||||
"integrity": "sha512-OSFRirL5beveGj4An3lOzWwg/JZWJG4Q1TdbyW7lqSDacfwINpIjSHdWlpiQwIghKU7BtLAc6TonUGlU4MzGTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/core": "^21.0.0",
|
||||
"rxjs": "^7.8.2"
|
||||
"@angular/common": ">=20.0.0",
|
||||
"@angular/core": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
|
||||
@ -32,7 +32,8 @@
|
||||
"@angular/router": "^21.0.0",
|
||||
"@fortawesome/angular-fontawesome": "^4.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"ngx-toastr": "^20.0.5",
|
||||
"@tanstack/angular-query-experimental": "^5.90.16",
|
||||
"ngx-sse-client": "^20.0.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { authInterceptor } from './shared/interceptors/auth.interceptor';
|
||||
import { provideToastr } from 'ngx-toastr';
|
||||
import { provideTanStackQuery, QueryClient } from '@tanstack/angular-query-experimental';
|
||||
import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideToastr(),
|
||||
provideTanStackQuery(new QueryClient(), withDevtools()),
|
||||
],
|
||||
};
|
||||
|
||||
@ -3,8 +3,6 @@ import { RecipePage } from './pages/recipe-page/recipe-page';
|
||||
import { RecipesPage } from './pages/recipes-page/recipes-page';
|
||||
import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page';
|
||||
import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page';
|
||||
import { authGuard } from './shared/guards/auth-guard';
|
||||
import { RecipeEditPage } from './pages/recipe-edit-page/recipe-edit-page';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@ -18,14 +16,9 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'recipe-upload',
|
||||
component: RecipeUploadPage,
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: 'recipes/:username/:slug',
|
||||
component: RecipePage,
|
||||
},
|
||||
{
|
||||
path: 'recipes/:username/:slug/edit',
|
||||
component: RecipeEditPage,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
export const Endpoints = {
|
||||
authLogin: 'auth/login',
|
||||
authLogout: 'auth/logout',
|
||||
authRefresh: 'auth/refresh',
|
||||
images: 'images',
|
||||
recipes: 'recipes',
|
||||
recipeDrafts: 'recipe-drafts',
|
||||
};
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
<h1>Edit Recipe</h1>
|
||||
@if (loadingRecipe()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadRecipeError()) {
|
||||
<p>There was an error loading the recipe: {{ loadRecipeError() }}</p>
|
||||
} @else {
|
||||
@let recipe = recipeView()!.recipe;
|
||||
<div class="recipe-info-container">
|
||||
<p>Created: {{ recipe.created | date: "short" }}</p>
|
||||
@if (recipe.modified) {
|
||||
<p>Last modified: {{ recipe.modified | date: "short" }}</p>
|
||||
}
|
||||
</div>
|
||||
<app-recipe-edit-form
|
||||
[recipe]="recipe"
|
||||
[editSlugDisabled]="true"
|
||||
(submitRecipe)="onRecipeSubmit($event)"
|
||||
></app-recipe-edit-form>
|
||||
@if (submittingRecipe()) {
|
||||
<app-spinner></app-spinner>
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RecipeEditPage } from './recipe-edit-page';
|
||||
|
||||
describe('RecipeEditPage', () => {
|
||||
let component: RecipeEditPage;
|
||||
let fixture: ComponentFixture<RecipeEditPage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecipeEditPage],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RecipeEditPage);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,73 +0,0 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FullRecipeViewWrapper } from '../../shared/models/Recipe.model';
|
||||
import { RecipeService } from '../../shared/services/RecipeService';
|
||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { RecipeEditForm } from '../../shared/components/recipe-edit-form/recipe-edit-form';
|
||||
import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-edit-page',
|
||||
imports: [Spinner, DatePipe, RecipeEditForm],
|
||||
templateUrl: './recipe-edit-page.html',
|
||||
styleUrl: './recipe-edit-page.css',
|
||||
})
|
||||
export class RecipeEditPage implements OnInit {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
|
||||
protected readonly loadingRecipe = signal(false);
|
||||
protected readonly loadRecipeError = signal<Error | null>(null);
|
||||
protected readonly recipeView = signal<FullRecipeViewWrapper | null>(null);
|
||||
|
||||
protected readonly submittingRecipe = signal(false);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||
const username = paramMap.get('username')!;
|
||||
const slug = paramMap.get('slug')!;
|
||||
this.loadingRecipe.set(true);
|
||||
this.recipeService.getRecipeView(username, slug, true).subscribe({
|
||||
next: (recipeView) => {
|
||||
this.loadingRecipe.set(false);
|
||||
this.recipeView.set(recipeView);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingRecipe.set(false);
|
||||
this.loadRecipeError.set(e);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onRecipeSubmit(event: RecipeEditFormSubmitEvent): void {
|
||||
this.submittingRecipe.set(true);
|
||||
const baseRecipe = this.recipeView()!.recipe;
|
||||
this.recipeService
|
||||
.updateRecipe(baseRecipe.owner.username, baseRecipe.slug, {
|
||||
...event,
|
||||
mainImage: event.mainImage
|
||||
? {
|
||||
username: event.mainImage.owner.username,
|
||||
filename: event.mainImage.filename,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.subscribe({
|
||||
next: async () => {
|
||||
this.submittingRecipe.set(false);
|
||||
await this.router.navigate(['recipes', baseRecipe.owner.username, baseRecipe.slug]);
|
||||
this.toastrService.success('Recipe updated');
|
||||
},
|
||||
error: (e) => {
|
||||
this.submittingRecipe.set(false);
|
||||
console.error(e);
|
||||
this.toastrService.error('Error submitting recipe');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -26,36 +26,3 @@ article {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
.actions-button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 5px;
|
||||
}
|
||||
|
||||
.timings-container p {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.ingredients-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 5px;
|
||||
}
|
||||
|
||||
.ingredient {
|
||||
display: flex;
|
||||
column-gap: 5px;
|
||||
}
|
||||
|
||||
@ -3,24 +3,12 @@
|
||||
<div id="recipe-header">
|
||||
<div>
|
||||
<h1>{{ recipe.title }}</h1>
|
||||
<div class="recipe-actions">
|
||||
@if (isLoggedIn()) {
|
||||
<button
|
||||
id="star"
|
||||
[matButton]="recipeView().isStarred ? 'filled' : 'outlined'"
|
||||
(click)="onToggleStar()"
|
||||
>
|
||||
<button id="star" matButton="filled" (click)="starMutation.mutate()">
|
||||
<div id="star-label">
|
||||
<fa-icon [icon]="faStar" />
|
||||
@if (recipeView().isStarred) {
|
||||
<span>Starred</span>
|
||||
} @else {
|
||||
<span>Star</span>
|
||||
}
|
||||
<span id="star-count">{{ recipe.starCount }}</span>
|
||||
@if (togglingStar()) {
|
||||
<app-spinner size="12px"></app-spinner>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
@ -29,16 +17,6 @@
|
||||
<span id="star-count">{{ recipe.starCount }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isOwner()) {
|
||||
<button class="actions-button" matButton="text" [matMenuTriggerFor]="recipeActionsMenu">
|
||||
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #recipeActionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="onRecipeEdit()">Edit recipe</button>
|
||||
<button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
@ -52,56 +30,15 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (loadingMainImage()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadMainImageError()) {
|
||||
<p>There was an error loading the main image.</p>
|
||||
} @else if (mainImage()) {
|
||||
@if (mainImageUrl.isSuccess()) {
|
||||
<img
|
||||
id="main-image"
|
||||
[src]="mainImage()"
|
||||
[alt]="recipe.mainImage!.alt"
|
||||
[height]="recipe.mainImage!.height"
|
||||
[width]="recipe.mainImage!.width"
|
||||
[src]="mainImageUrl.data()"
|
||||
[alt]="recipe.mainImage.alt"
|
||||
[height]="recipe.mainImage.height"
|
||||
[width]="recipe.mainImage.width"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (hasTiming()) {
|
||||
<div class="timings-container">
|
||||
<h3>Timings</h3>
|
||||
@if (recipe.preparationTime) {
|
||||
<p>Preparation time: {{ recipe.preparationTime }} minutes</p>
|
||||
}
|
||||
@if (recipe.cookingTime) {
|
||||
<p>Cooking time: {{ recipe.cookingTime }} minutes</p>
|
||||
}
|
||||
@if (recipe.totalTime) {
|
||||
<p>Total time: {{ recipe.totalTime }} minutes</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recipe.ingredients?.length) {
|
||||
<h3>Ingredients</h3>
|
||||
<ul class="ingredients-list">
|
||||
@for (ingredient of recipe.ingredients; track $index) {
|
||||
<li class="ingredient">
|
||||
@if (ingredient.amount) {
|
||||
<span>{{ ingredient.amount }}</span>
|
||||
}
|
||||
<span>{{ ingredient.name }}</span>
|
||||
@if (ingredient.notes) {
|
||||
<span>{{ ingredient.notes }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
<h3>Instructions</h3>
|
||||
<div [innerHTML]="recipe.text"></div>
|
||||
</div>
|
||||
|
||||
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />
|
||||
</article>
|
||||
|
||||
@ -1,112 +1,43 @@
|
||||
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { FullRecipeViewWrapper } from '../../../shared/models/Recipe.model';
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { RecipeView } from '../../../shared/models/Recipe.model';
|
||||
import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental';
|
||||
import { ImageService } from '../../../shared/services/ImageService';
|
||||
import { faEllipsis, 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 { RecipeService } from '../../../shared/services/RecipeService';
|
||||
import { AuthService } from '../../../shared/services/AuthService';
|
||||
import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { Spinner } from '../../../shared/components/spinner/spinner';
|
||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ConfirmationDialog } from '../../../shared/components/confirmation-dialog/confirmation-dialog';
|
||||
import { ConfirmationDialogData } from '../../../shared/components/confirmation-dialog/ConfirmationDialogData';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-page-content',
|
||||
imports: [FaIconComponent, RecipeCommentsList, MatButton, Spinner, MatMenuTrigger, MatMenu, MatMenuItem],
|
||||
imports: [FaIconComponent, RecipeCommentsList, MatButton],
|
||||
templateUrl: './recipe-page-content.html',
|
||||
styleUrl: './recipe-page-content.css',
|
||||
})
|
||||
export class RecipePageContent implements OnInit {
|
||||
public readonly recipeView = input.required<FullRecipeViewWrapper>();
|
||||
public readonly requireHotReload = output<void>();
|
||||
export class RecipePageContent {
|
||||
public recipeView = input.required<RecipeView>();
|
||||
|
||||
private readonly imageService = inject(ImageService);
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
|
||||
protected readonly isOwner = computed(() => !!this.recipeView().isOwner);
|
||||
|
||||
protected readonly loadingMainImage = signal(false);
|
||||
protected readonly loadMainImageError = signal<Error | null>(null);
|
||||
protected readonly mainImage = signal<string | null>(null);
|
||||
|
||||
protected readonly hasTiming = computed(() => {
|
||||
protected readonly mainImageUrl = injectQuery(() => {
|
||||
const recipe = this.recipeView().recipe;
|
||||
return !!recipe.preparationTime || !!recipe.cookingTime || !!recipe.totalTime;
|
||||
return {
|
||||
queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
|
||||
queryFn: () => this.imageService.getImage(recipe.mainImage.url),
|
||||
};
|
||||
});
|
||||
|
||||
protected readonly togglingStar = signal(false);
|
||||
|
||||
public ngOnInit(): void {
|
||||
const recipe = this.recipeView().recipe;
|
||||
if (recipe.mainImage) {
|
||||
this.loadingMainImage.set(true);
|
||||
this.imageService.getImageBlobUrl(recipe.mainImage.owner.username, recipe.mainImage.filename).subscribe({
|
||||
next: (blobUrl) => {
|
||||
this.loadingMainImage.set(false);
|
||||
this.mainImage.set(blobUrl);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingMainImage.set(false);
|
||||
this.loadMainImageError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
|
||||
protected async onRecipeEdit(): Promise<void> {
|
||||
const recipe = this.recipeView().recipe;
|
||||
await this.router.navigate(['recipes', recipe.owner.username, recipe.slug, 'edit']);
|
||||
}
|
||||
|
||||
protected onRecipeDelete(): void {
|
||||
const dialogRef = this.dialog.open<ConfirmationDialog, ConfirmationDialogData, boolean>(ConfirmationDialog, {
|
||||
data: {
|
||||
title: 'Delete Recipe',
|
||||
message: 'Are you sure you wish to delete this recipe?',
|
||||
},
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
const recipe = this.recipeView().recipe;
|
||||
this.recipeService.deleteRecipe(recipe.owner.username, recipe.slug).subscribe(async () => {
|
||||
this.toastrService.success('Recipe successfully deleted');
|
||||
await this.router.navigate(['/']);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected onToggleStar(): void {
|
||||
this.togglingStar.set(true);
|
||||
const recipe = this.recipeView().recipe;
|
||||
this.recipeService.toggleStar(recipe.owner.username, recipe.slug).subscribe({
|
||||
next: () => {
|
||||
this.togglingStar.set(false);
|
||||
this.requireHotReload.emit();
|
||||
},
|
||||
error: (e) => {
|
||||
this.togglingStar.set(false);
|
||||
this.toastrService.error('There was an error toggling the star');
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
protected readonly starMutation = injectMutation(() => ({
|
||||
mutationFn: () => this.recipeService.toggleStar(this.recipeView()),
|
||||
}));
|
||||
|
||||
protected readonly faStar = faStar;
|
||||
protected readonly faUser = faUser;
|
||||
protected readonly faGlobe = faGlobe;
|
||||
protected readonly faLock = faLock;
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
@if (loadingRecipe()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadRecipeError()) {
|
||||
@if (recipeView.isLoading()) {
|
||||
<p>Loading...</p>
|
||||
} @else if (recipeView.isSuccess()) {
|
||||
<app-recipe-page-content [recipeView]="recipeView.data()"></app-recipe-page-content>
|
||||
} @else if (recipeView.error(); as error) {
|
||||
<p>{{ error.message }}</p>
|
||||
} @else {
|
||||
<p>There was an error loading the recipe.</p>
|
||||
} @else if (recipe(); as recipe) {
|
||||
<app-recipe-page-content [recipeView]="recipe" (requireHotReload)="onRequireHotReload()"></app-recipe-page-content>
|
||||
}
|
||||
|
||||
@ -1,50 +1,23 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { RecipeService } from '../../shared/services/RecipeService';
|
||||
import { RecipePageContent } from './recipe-page-content/recipe-page-content';
|
||||
import { FullRecipeViewWrapper } from '../../shared/models/Recipe.model';
|
||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-page',
|
||||
imports: [RecipePageContent, Spinner],
|
||||
imports: [RecipePageContent],
|
||||
templateUrl: './recipe-page.html',
|
||||
styleUrl: './recipe-page.css',
|
||||
})
|
||||
export class RecipePage implements OnInit {
|
||||
export class RecipePage {
|
||||
private recipeService = inject(RecipeService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private username = this.route.snapshot.paramMap.get('username') as string;
|
||||
private slug = this.route.snapshot.paramMap.get('slug') as string;
|
||||
|
||||
protected readonly loadingRecipe = signal(false);
|
||||
protected readonly loadRecipeError = signal<Error | null>(null);
|
||||
protected readonly recipe = signal<FullRecipeViewWrapper | null>(null);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.loadingRecipe.set(true);
|
||||
this.recipeService.getRecipeView(this.username, this.slug).subscribe({
|
||||
next: (recipe) => {
|
||||
this.loadingRecipe.set(false);
|
||||
this.recipe.set(recipe);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingRecipe.set(false);
|
||||
this.loadRecipeError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected onRequireHotReload(): void {
|
||||
this.recipeService.getRecipeView(this.username, this.slug).subscribe({
|
||||
next: (recipe) => {
|
||||
this.recipe.set(recipe);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadRecipeError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
protected recipeView = injectQuery(() => ({
|
||||
queryKey: ['recipe', this.username, this.slug],
|
||||
queryFn: () => this.recipeService.getRecipeView(this.username, this.slug),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<app-recipe-upload-trail
|
||||
<div id="recipe-upload-container">
|
||||
<h1>Upload Recipe</h1>
|
||||
<app-recipe-upload-trail
|
||||
[displayStep]="displayStep()"
|
||||
[inProgressStep]="inProgressStep()"
|
||||
[includeInfer]="includeInfer()"
|
||||
(stepClick)="onStepClick($event)"
|
||||
></app-recipe-upload-trail>
|
||||
></app-recipe-upload-trail>
|
||||
|
||||
@if (displayStep() === RecipeUploadStep.START) {
|
||||
@if (displayStep() === RecipeUploadStep.START) {
|
||||
<app-ai-or-manual
|
||||
[sourceFile]="sourceFile()"
|
||||
(sourceFileChange)="onSourceFileChange($event)"
|
||||
(submitStep)="onAiOrManualSubmit($event)"
|
||||
></app-ai-or-manual>
|
||||
} @else if (displayStep() === RecipeUploadStep.INFER) {
|
||||
} @else if (displayStep() === RecipeUploadStep.INFER) {
|
||||
<app-infer></app-infer>
|
||||
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||
<app-enter-recipe-data
|
||||
[draft]="model().draft!"
|
||||
(recipeSubmit)="onEnterRecipeDataSubmit($event)"
|
||||
(deleteDraft)="onDeleteDraft()"
|
||||
></app-enter-recipe-data>
|
||||
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
|
||||
<app-review [draft]="model().draft!" (publish)="onPublish()"></app-review>
|
||||
}
|
||||
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||
<app-enter-recipe-data [model]="model()"></app-enter-recipe-data>
|
||||
}
|
||||
|
||||
<!--
|
||||
<section>
|
||||
<h2>Auto-Complete Recipe (Optional)</h2>
|
||||
<p>Choose a photo of a recipe from your files, and AI will fill out the form below for you.</p>
|
||||
<form id="recipe-upload-form" [formGroup]="recipeUploadForm" (ngSubmit)="onFileSubmit()">
|
||||
<input id="file" type="file" (change)="onFileChange($event)" />
|
||||
<div class="action-buttons-container">
|
||||
<button matButton="outlined" type="button" (click)="onClear()">Clear</button>
|
||||
<button matButton="filled" type="submit" [disabled]="!recipeUploadForm.valid || inferenceInProgress()">
|
||||
AI Auto-Complete
|
||||
</button>
|
||||
<app-spinner [enabled]="inferenceInProgress()"></app-spinner>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Recipe Form</h2>
|
||||
<div id="recipe-form-and-source">
|
||||
<form [formGroup]="recipeForm" (ngSubmit)="onRecipeSubmit()">
|
||||
<mat-form-field>
|
||||
<mat-label>Recipe Title</mat-label>
|
||||
<input matInput id="recipe-title" type="text" formControlName="title" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Recipe Text</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
id="recipe-text"
|
||||
formControlName="recipeText"
|
||||
(input)="onRecipeTextChange($event)"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<button matButton="filled" type="submit" [disabled]="!recipeForm.valid || inferenceInProgress()">Add Recipe</button>
|
||||
</form>
|
||||
<div>
|
||||
@if (sourceRecipeImage()) {
|
||||
<img [src]="sourceRecipeImage()" alt="Your source recipe image." />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
-->
|
||||
</div>
|
||||
|
||||
@ -7,68 +7,59 @@ import { EnterRecipeData } from './steps/enter-recipe-data/enter-recipe-data';
|
||||
import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { StepClickEvent } from './recipe-upload-trail/StepClickEvent';
|
||||
import { RecipeUploadClientModel } from '../../shared/client-models/RecipeUploadClientModel';
|
||||
import { RecipeDraftService } from '../../shared/services/RecipeDraftService';
|
||||
import { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel';
|
||||
import { RecipeUploadService } from '../../shared/services/RecipeUploadService';
|
||||
import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
|
||||
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
|
||||
import { tryMaybeInt } from '../../shared/util';
|
||||
import { from, map, switchMap, tap } from 'rxjs';
|
||||
import { Review } from './steps/review/review';
|
||||
import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-upload-page',
|
||||
imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail, Review],
|
||||
imports: [ReactiveFormsModule, AiOrManual, Infer, EnterRecipeData, RecipeUploadTrail],
|
||||
templateUrl: './recipe-upload-page.html',
|
||||
styleUrl: './recipe-upload-page.css',
|
||||
})
|
||||
export class RecipeUploadPage implements OnInit {
|
||||
protected readonly model = signal<RecipeUploadClientModel>({
|
||||
protected readonly model = signal<RecipeUploadModel>({
|
||||
inProgressStep: RecipeUploadStep.START,
|
||||
});
|
||||
|
||||
protected readonly displayStep = signal<number>(RecipeUploadStep.START);
|
||||
protected readonly inProgressStep = computed(() => this.model().inProgressStep);
|
||||
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 activatedRoute = inject(ActivatedRoute);
|
||||
private readonly recipeDraftService = inject(RecipeDraftService);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
private readonly recipeUploadService = inject(RecipeUploadService);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.activatedRoute.queryParamMap
|
||||
.pipe(
|
||||
map((paramMap) => {
|
||||
const draftIdParam: string | null = paramMap.get('draftId');
|
||||
const draftId = tryMaybeInt(draftIdParam);
|
||||
const stepParam: string | null = paramMap.get('step');
|
||||
const step = tryMaybeInt(stepParam);
|
||||
return [paramMap.get('draftId'), step] as const;
|
||||
return [draftId, step];
|
||||
}),
|
||||
switchMap(([draftId, step]) => {
|
||||
if (draftId !== null) {
|
||||
return this.recipeDraftService.getRecipeUploadClientModel(draftId).pipe(
|
||||
tap(async (recipeUploadClientModel) => {
|
||||
await this.switchModel(recipeUploadClientModel);
|
||||
const currentModel = this.model();
|
||||
if (draftId !== null && currentModel.id !== draftId) {
|
||||
return this.recipeUploadService.getRecipeUploadModel(draftId).pipe(
|
||||
tap((updatedModel) => {
|
||||
this.model.set(updatedModel);
|
||||
}),
|
||||
switchMap((updatedModel) => {
|
||||
if (step !== null && this.isValidStep(step)) {
|
||||
if (step !== null && step <= updatedModel.inProgressStep) {
|
||||
return from(this.changeDisplayStep(step));
|
||||
} else {
|
||||
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));
|
||||
} else {
|
||||
return from(this.changeDisplayStep(RecipeUploadStep.START));
|
||||
@ -78,28 +69,15 @@ export class RecipeUploadPage implements OnInit {
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private async switchModel(
|
||||
model: RecipeUploadClientModel,
|
||||
switchStep: boolean | RecipeUploadStep = false,
|
||||
): Promise<void> {
|
||||
this.model.set(model);
|
||||
this.includeInfer.set(!!model.draft?.lastInference || model.inProgressStep === RecipeUploadStep.INFER);
|
||||
if (switchStep === true) {
|
||||
await this.changeDisplayStep(model.inProgressStep);
|
||||
} else if (typeof switchStep === 'number') {
|
||||
await this.changeDisplayStep(switchStep);
|
||||
}
|
||||
}
|
||||
|
||||
private async changeDisplayStep(targetStep: number): Promise<void> {
|
||||
this.displayStep.set(targetStep);
|
||||
await this.router.navigate([], {
|
||||
relativeTo: this.activatedRoute,
|
||||
queryParams: {
|
||||
draftId: this.model().draft?.id,
|
||||
step: targetStep,
|
||||
draftId: this.model().id,
|
||||
},
|
||||
queryParamsHandling: 'replace',
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
@ -111,68 +89,124 @@ export class RecipeUploadPage implements OnInit {
|
||||
if (event._tag === 'file-add-event') {
|
||||
this.model.update((model) => ({
|
||||
...model,
|
||||
inputSourceFile: event.file,
|
||||
sourceFile: event.file,
|
||||
}));
|
||||
} else {
|
||||
this.model.update((model) => ({
|
||||
...model,
|
||||
inputSourceFile: null,
|
||||
sourceFile: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
protected async onAiOrManualSubmit(event: AIOrManualSubmitEvent): Promise<void> {
|
||||
if (event.mode === 'manual') {
|
||||
const model = await this.recipeDraftService.createManualDraft();
|
||||
await this.switchModel(model, true);
|
||||
this.model.update((model) => ({
|
||||
...model,
|
||||
sourceFile: null,
|
||||
inProgressStep: RecipeUploadStep.ENTER_DATA,
|
||||
}));
|
||||
await this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
||||
this.includeInfer.set(false);
|
||||
} else {
|
||||
await this.switchModel(
|
||||
{
|
||||
...this.model(),
|
||||
inputSourceFile: this.sourceFile(),
|
||||
this.model.update((model) => ({
|
||||
...model,
|
||||
sourceFile: this.sourceFile(),
|
||||
inProgressStep: RecipeUploadStep.INFER,
|
||||
},
|
||||
true,
|
||||
);
|
||||
this.recipeDraftService
|
||||
.startInference(this.model())
|
||||
.pipe(
|
||||
tap((updated) => {
|
||||
this.switchModel(updated, true);
|
||||
}),
|
||||
switchMap((model) => this.recipeDraftService.pollUntilInferenceComplete(model)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (inferredModel) => {
|
||||
this.switchModel(inferredModel, true);
|
||||
},
|
||||
error: (e) => {
|
||||
console.error(e);
|
||||
this.toastrService.error('Error while extracting recipe data');
|
||||
},
|
||||
}));
|
||||
await this.changeDisplayStep(RecipeUploadStep.INFER);
|
||||
this.includeInfer.set(true);
|
||||
this.recipeUploadService.doInference(this.model()).subscribe((updatedModel) => {
|
||||
this.model.set(updatedModel);
|
||||
this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async onEnterRecipeDataSubmit(event: RecipeEditFormSubmitEvent): Promise<void> {
|
||||
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
|
||||
await this.switchModel(model, RecipeUploadStep.REVIEW);
|
||||
}
|
||||
|
||||
protected async onDeleteDraft(): Promise<void> {
|
||||
await this.recipeDraftService.deleteDraft(this.model().draft!.id);
|
||||
await this.switchModel(
|
||||
{
|
||||
inProgressStep: RecipeUploadStep.START,
|
||||
},
|
||||
RecipeUploadStep.START,
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// private readonly sseClient = inject(SseClient);
|
||||
// private readonly formBuilder = inject(FormBuilder);
|
||||
//
|
||||
// protected readonly sourceRecipeImage = signal<string | null>(null);
|
||||
// protected readonly inferenceInProgress = signal(false);
|
||||
//
|
||||
// protected readonly recipeUploadForm = this.formBuilder.group({
|
||||
// file: this.formBuilder.control<File | null>(null, [Validators.required]),
|
||||
// });
|
||||
//
|
||||
// protected readonly recipeForm = new FormGroup({
|
||||
// title: new FormControl('', [Validators.required]),
|
||||
// recipeText: new FormControl('', Validators.required),
|
||||
// });
|
||||
//
|
||||
// protected onClear() {
|
||||
// this.recipeUploadForm.reset();
|
||||
// this.sourceRecipeImage.set(null);
|
||||
// }
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
'step-complete': step.completed,
|
||||
'step-in-progress': step.inProgress,
|
||||
'step-incomplete': !step.completed,
|
||||
'step-displayed': displayStep() === step.index,
|
||||
'step-displayed': displayStep() === step.index
|
||||
}"
|
||||
>
|
||||
@if (step.completed || step.inProgress) {
|
||||
|
||||
@ -34,12 +34,6 @@ export class RecipeUploadTrail {
|
||||
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
|
||||
inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA,
|
||||
},
|
||||
{
|
||||
index: RecipeUploadStep.REVIEW,
|
||||
name: 'Review',
|
||||
completed: this.inProgressStep() > RecipeUploadStep.REVIEW,
|
||||
inProgress: this.inProgressStep() === RecipeUploadStep.REVIEW,
|
||||
},
|
||||
];
|
||||
if (this.includeInfer()) {
|
||||
base.push({
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
.in-progress-draft-link,
|
||||
.in-progress-draft-list-item {
|
||||
display: flex;
|
||||
column-gap: 5px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.in-progress-draft-link * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.last-saved {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.no-title {
|
||||
font-style: italic;
|
||||
}
|
||||
@ -1,40 +1,11 @@
|
||||
<section>
|
||||
<h2>Start</h2>
|
||||
<section>
|
||||
@if (loadingDrafts()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (drafts().length) {
|
||||
<h3>In Progress Drafts</h3>
|
||||
<ul>
|
||||
@for (draft of drafts(); track $index) {
|
||||
<li class="in-progress-draft-list-item">
|
||||
<a class="in-progress-draft-link" (click)="onInProgressDraftClick(draft)">
|
||||
<fa-icon [icon]="faFilePen"></fa-icon>
|
||||
@if (draft.title) {
|
||||
<span>{{ draft.title }}</span>
|
||||
} @else {
|
||||
<span class="no-title">(No title)</span>
|
||||
}
|
||||
</a>
|
||||
<span class="last-saved"
|
||||
>(Last saved {{ (draft.modified ? draft.modified : draft.created) | date: "short" }})</span
|
||||
>
|
||||
</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>
|
||||
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">Use AI Assist</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@ -1,33 +1,21 @@
|
||||
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent';
|
||||
import { FileUpload } from '../../../../shared/components/file-upload/file-upload';
|
||||
import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent';
|
||||
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';
|
||||
import { Spinner } from '../../../../shared/components/spinner/spinner';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ai-or-manual',
|
||||
imports: [MatButton, ReactiveFormsModule, FileUpload, FaIconComponent, Spinner, DatePipe],
|
||||
imports: [MatButton, ReactiveFormsModule, FileUpload],
|
||||
templateUrl: './ai-or-manual.html',
|
||||
styleUrl: './ai-or-manual.css',
|
||||
})
|
||||
export class AiOrManual implements OnInit {
|
||||
export class AiOrManual {
|
||||
public sourceFile = input.required<File | null>();
|
||||
public sourceFileChange = output<FileUploadEvent>();
|
||||
public submitStep = output<AIOrManualSubmitEvent>();
|
||||
|
||||
private readonly recipeDraftService = inject(RecipeDraftService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
|
||||
protected readonly sourceFilesArray = computed(() => {
|
||||
const maybeSourceFile = this.sourceFile();
|
||||
if (maybeSourceFile) {
|
||||
@ -37,24 +25,6 @@ export class AiOrManual implements OnInit {
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly loadingDrafts = signal(false);
|
||||
protected readonly drafts = signal<RecipeDraftViewModel[]>([]);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.loadingDrafts.set(true);
|
||||
this.recipeDraftService.getInProgressDrafts().subscribe({
|
||||
next: (drafts) => {
|
||||
this.loadingDrafts.set(false);
|
||||
this.drafts.set(drafts);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingDrafts.set(false);
|
||||
console.error(e);
|
||||
this.toastrService.error('There was an error Recipe drafts');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected onFileChange(event: FileUploadEvent) {
|
||||
this.sourceFileChange.emit(event);
|
||||
}
|
||||
@ -62,14 +32,4 @@ export class AiOrManual implements OnInit {
|
||||
protected onFormSubmit(mode: 'manual' | 'ai-assist') {
|
||||
this.submitStep.emit({ mode });
|
||||
}
|
||||
|
||||
protected async onInProgressDraftClick(draft: RecipeDraftViewModel) {
|
||||
await this.router.navigate(['/recipe-upload'], {
|
||||
queryParams: {
|
||||
draftId: draft.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly faFilePen = faFilePen;
|
||||
}
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
.draft-info-container {
|
||||
width: 60ch;
|
||||
form {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.draft-actions-button {
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
width: 60ch;
|
||||
}
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
<h2>Enter Recipe</h2>
|
||||
<div class="draft-info-container">
|
||||
<div>
|
||||
<p>Draft started: {{ draft().created | date: "short" }}</p>
|
||||
<p>Last saved: {{ draft().modified | date: "short" }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<button matButton="text" [matMenuTriggerFor]="draftActionsMenu" class="draft-actions-button">
|
||||
<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>
|
||||
<app-recipe-edit-form [recipe]="draft()" (submitRecipe)="onSubmit($event)"></app-recipe-edit-form>
|
||||
<form [formGroup]="recipeFormGroup">
|
||||
<mat-form-field>
|
||||
<mat-label>Title</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.title">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Slug</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.slug">
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Recipe Text</mat-label>
|
||||
<textarea matInput [formControl]="recipeFormGroup.controls.text"></textarea>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { EnterRecipeData } from './enter-recipe-data';
|
||||
import { inputBinding } from '@angular/core';
|
||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
||||
|
||||
describe('EnterRecipeData', () => {
|
||||
let component: EnterRecipeData;
|
||||
@ -10,12 +9,9 @@ describe('EnterRecipeData', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EnterRecipeData],
|
||||
providers: [],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EnterRecipeData, {
|
||||
bindings: [inputBinding('draft', () => ({}) as RecipeDraftViewModel)],
|
||||
});
|
||||
fixture = TestBed.createComponent(EnterRecipeData);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
@ -1,41 +1,29 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { RecipeEditFormSubmitEvent } from '../../../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RecipeEditForm } from '../../../../shared/components/recipe-edit-form/recipe-edit-form';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, input, OnInit } from '@angular/core';
|
||||
import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
|
||||
@Component({
|
||||
selector: 'app-enter-recipe-data',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButton,
|
||||
MatMenuTrigger,
|
||||
FaIconComponent,
|
||||
MatMenu,
|
||||
MatMenuItem,
|
||||
RecipeEditForm,
|
||||
DatePipe,
|
||||
],
|
||||
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput],
|
||||
templateUrl: './enter-recipe-data.html',
|
||||
styleUrl: './enter-recipe-data.css',
|
||||
})
|
||||
export class EnterRecipeData {
|
||||
public readonly draft = input.required<RecipeDraftViewModel>();
|
||||
public readonly recipeSubmit = output<RecipeEditFormSubmitEvent>();
|
||||
public readonly deleteDraft = output<void>();
|
||||
export class EnterRecipeData implements OnInit {
|
||||
public readonly model = input.required<RecipeUploadModel>();
|
||||
|
||||
protected onSubmit(event: RecipeEditFormSubmitEvent): void {
|
||||
this.recipeSubmit.emit(event);
|
||||
public ngOnInit(): void {
|
||||
const model = this.model();
|
||||
this.recipeFormGroup.patchValue({
|
||||
title: model.userTitle ?? model.inferredTitle ?? '',
|
||||
slug: model.userSlug ?? model.inferredSlug ?? '',
|
||||
text: model.userText ?? model.inferredText ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
protected onDraftDelete(): void {
|
||||
this.deleteDraft.emit();
|
||||
}
|
||||
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
protected readonly recipeFormGroup = new FormGroup({
|
||||
title: new FormControl('', Validators.required),
|
||||
slug: new FormControl('', Validators.required),
|
||||
text: new FormControl('', Validators.required),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,15 +1,8 @@
|
||||
<h1>Recipes</h1>
|
||||
@if (loadingRecipes()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadRecipesError()) {
|
||||
<p>There was an error loading recipes: {{ loadRecipesError() }}</p>
|
||||
} @else {
|
||||
<app-recipe-card-grid [recipes]="recipes()"></app-recipe-card-grid>
|
||||
<mat-paginator
|
||||
[length]="recipeCount()"
|
||||
[pageIndex]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 25, 50]"
|
||||
(page)="onPage($event)"
|
||||
></mat-paginator>
|
||||
@if (recipes.isSuccess()) {
|
||||
<app-recipe-card-grid [recipes]="recipes.data()" />
|
||||
} @else if (recipes.isLoading()) {
|
||||
<p>Loading...</p>
|
||||
} @else if (recipes.isError()) {
|
||||
<p>{{ recipes.error().message }}</p>
|
||||
}
|
||||
|
||||
@ -1,70 +1,19 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RecipeService } from '../../shared/services/RecipeService';
|
||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
||||
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
|
||||
import { RecipeInfoView } from '../../shared/models/Recipe.model';
|
||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { combineLatest } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipes-page',
|
||||
imports: [RecipeCardGrid, Spinner, MatPaginator],
|
||||
imports: [RecipeCardGrid],
|
||||
templateUrl: './recipes-page.html',
|
||||
styleUrl: './recipes-page.css',
|
||||
})
|
||||
export class RecipesPage implements OnInit {
|
||||
export class RecipesPage {
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
|
||||
protected readonly loadingRecipes = signal(false);
|
||||
protected readonly loadRecipesError = signal<Error | null>(null);
|
||||
protected readonly recipes = signal<RecipeInfoView[]>([]);
|
||||
|
||||
protected readonly recipeCount = signal(50);
|
||||
protected readonly currentPage = signal(0);
|
||||
protected readonly pageSize = signal(10);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.loadRecipes();
|
||||
}
|
||||
|
||||
private loadRecipes() {
|
||||
this.loadingRecipes.set(true);
|
||||
combineLatest([
|
||||
this.recipeService.getRecipes({
|
||||
page: this.currentPage(),
|
||||
size: this.pageSize(),
|
||||
}),
|
||||
this.recipeService.getRecipeCount(),
|
||||
]).subscribe({
|
||||
next: ([sliceView, count]) => {
|
||||
this.loadingRecipes.set(false);
|
||||
this.recipes.set(sliceView.content);
|
||||
this.recipeCount.set(count);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingRecipes.set(false);
|
||||
this.loadRecipesError.set(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected onPage(pageEvent: PageEvent): void {
|
||||
// chart
|
||||
// | size-change | size-same
|
||||
// page-change | reload | reload
|
||||
// page-same | reload | nothing
|
||||
if (pageEvent.pageSize !== this.pageSize() || pageEvent.pageIndex !== this.currentPage()) {
|
||||
this.pageSize.set(pageEvent.pageSize);
|
||||
this.currentPage.update((old) => {
|
||||
if (pageEvent.pageIndex < old) {
|
||||
return Math.max(old - 1, 0);
|
||||
} else if (pageEvent.pageIndex > old) {
|
||||
return (this.currentPage() + 1) * this.pageSize() <= this.recipeCount() ? old + 1 : old;
|
||||
} else {
|
||||
return old;
|
||||
}
|
||||
});
|
||||
this.loadRecipes();
|
||||
}
|
||||
}
|
||||
protected readonly recipes = injectQuery(() => ({
|
||||
queryKey: ['recipes'],
|
||||
queryFn: () => this.recipeService.getRecipes(),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -6,13 +6,13 @@
|
||||
</mat-form-field>
|
||||
<button matButton="filled" type="submit" [disabled]="!searchRecipesForm.valid">Search</button>
|
||||
</form>
|
||||
@if (loadingResults()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadResultsError()) {
|
||||
<p>There was an error during search. Try again.</p>
|
||||
} @else if (results()?.length) {
|
||||
<p>Showing results for '{{ submittedPrompt() }}'</p>
|
||||
<app-recipe-card-grid [recipes]="results()!" />
|
||||
} @else if (results()?.length === 0) {
|
||||
<p>There were no results for '{{ submittedPrompt() }}'</p>
|
||||
@if (givenPrompt() !== null) {
|
||||
@if (resultsQuery.isLoading()) {
|
||||
<p>Loading search results...</p>
|
||||
} @else if (resultsQuery.isSuccess()) {
|
||||
<p>Showing results for {{ givenPrompt() }}</p>
|
||||
<app-recipe-card-grid [recipes]="resultsQuery.data()" />
|
||||
} @else if (resultsQuery.isError()) {
|
||||
<p>There was an error during search.</p>
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,30 +1,28 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
||||
import { RecipeService } from '../../shared/services/RecipeService';
|
||||
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { RecipeInfoView } from '../../shared/models/Recipe.model';
|
||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipes-search-page',
|
||||
imports: [RecipeCardGrid, MatButton, MatFormField, MatInput, MatLabel, ReactiveFormsModule, Spinner],
|
||||
imports: [ReactiveFormsModule, RecipeCardGrid, MatButton, MatFormField, MatInput, MatLabel],
|
||||
templateUrl: './recipes-search-page.html',
|
||||
styleUrl: './recipes-search-page.css',
|
||||
})
|
||||
export class RecipesSearchPage implements OnInit {
|
||||
export class RecipesSearchPage {
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
public ngOnInit(): void {
|
||||
public constructor() {
|
||||
this.activatedRoute.queryParams.subscribe((queryParams) => {
|
||||
if (queryParams['prompt']) {
|
||||
const prompt = queryParams['prompt'] as string;
|
||||
this.searchRecipesForm.controls.prompt.setValue(prompt);
|
||||
this.loadResults(prompt);
|
||||
this.givenPrompt.set(queryParams['prompt']);
|
||||
this.searchRecipesForm.controls.prompt.setValue(queryParams['prompt']);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -33,34 +31,20 @@ export class RecipesSearchPage implements OnInit {
|
||||
prompt: new FormControl('', [Validators.required]),
|
||||
});
|
||||
|
||||
protected readonly submittedPrompt = signal<string | null>(null);
|
||||
protected readonly loadingResults = signal(false);
|
||||
protected readonly loadResultsError = signal<Error | null>(null);
|
||||
protected readonly results = signal<RecipeInfoView[] | null>(null);
|
||||
protected readonly givenPrompt = signal<null | string>(null);
|
||||
|
||||
private loadResults(prompt: string): void {
|
||||
this.submittedPrompt.set(prompt);
|
||||
this.loadingResults.set(true);
|
||||
this.recipeService.aiSearch(prompt).subscribe({
|
||||
next: (results) => {
|
||||
this.loadingResults.set(false);
|
||||
this.results.set(results);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingResults.set(false);
|
||||
this.loadResultsError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
protected readonly resultsQuery = injectQuery(() => ({
|
||||
queryFn: () => this.recipeService.aiSearch(this.givenPrompt()!),
|
||||
queryKey: ['recipes-search', this.givenPrompt()],
|
||||
enabled: () => !!this.givenPrompt(),
|
||||
}));
|
||||
|
||||
protected async onPromptSubmit() {
|
||||
if (this.searchRecipesForm.value.prompt) {
|
||||
const prompt = this.searchRecipesForm.value.prompt;
|
||||
await this.router.navigate(['/recipes-search'], {
|
||||
queryParams: { prompt },
|
||||
queryParams: { prompt: this.searchRecipesForm.value.prompt },
|
||||
});
|
||||
this.loadResults(prompt);
|
||||
this.givenPrompt.set(this.searchRecipesForm.value.prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
export interface ImageUpdateBody {
|
||||
alt?: string | null;
|
||||
caption?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
viewersToAdd?: string[] | null;
|
||||
viewersToRemove?: string[] | null;
|
||||
clearAllViewers?: boolean | null;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
export interface RecipeUpdateBody {
|
||||
title?: string | null;
|
||||
preparationTime?: number | null;
|
||||
cookingTime?: number | null;
|
||||
totalTime?: number | null;
|
||||
ingredients?: IngredientUpdateBody[] | null;
|
||||
rawText?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
mainImage?: MainImageUpdateBody | null;
|
||||
}
|
||||
|
||||
export interface IngredientUpdateBody {
|
||||
amount?: string | null;
|
||||
name: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface MainImageUpdateBody {
|
||||
username: string;
|
||||
filename: string;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { ImageView } from '../models/ImageView.model';
|
||||
|
||||
export interface ImageViewWithBlobUrl extends ImageView {
|
||||
blobUrl: string;
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { IngredientDraft } from '../models/RecipeDraftView.model';
|
||||
|
||||
export interface IngredientDraftClientModel {
|
||||
id: number;
|
||||
draft: IngredientDraft;
|
||||
}
|
||||
@ -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,
|
||||
INFER,
|
||||
ENTER_DATA,
|
||||
REVIEW,
|
||||
}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
export interface ConfirmationDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
.confirmation-actions {
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
<app-dialog-container [title]="data.title">
|
||||
<p>{{ data.message }}</p>
|
||||
<div class="confirmation-actions">
|
||||
<button matButton="outlined" (click)="onCancel()">Cancel</button>
|
||||
<button matButton="filled" (click)="onOk()">Ok</button>
|
||||
</div>
|
||||
</app-dialog-container>
|
||||
@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmationDialog } from './confirmation-dialog';
|
||||
|
||||
describe('ConfirmationDialog', () => {
|
||||
let component: ConfirmationDialog;
|
||||
let fixture: ComponentFixture<ConfirmationDialog>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConfirmationDialog],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,25 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { DialogContainer } from '../dialog-container/dialog-container';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { ConfirmationDialogData } from './ConfirmationDialogData';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirmation-dialog',
|
||||
imports: [DialogContainer, MatButton],
|
||||
templateUrl: './confirmation-dialog.html',
|
||||
styleUrl: './confirmation-dialog.css',
|
||||
})
|
||||
export class ConfirmationDialog {
|
||||
protected readonly data: ConfirmationDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
private readonly dialogRef: MatDialogRef<ConfirmationDialogData, boolean> = inject(MatDialogRef);
|
||||
|
||||
protected onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
protected onOk(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
.dialog-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
<div class="dialog-container">
|
||||
<h3>{{ title() }}</h3>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DialogContainer } from './dialog-container';
|
||||
|
||||
describe('DialogContainer', () => {
|
||||
let component: DialogContainer;
|
||||
let fixture: ComponentFixture<DialogContainer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DialogContainer],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DialogContainer);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dialog-container',
|
||||
imports: [],
|
||||
templateUrl: './dialog-container.html',
|
||||
styleUrl: './dialog-container.css',
|
||||
})
|
||||
export class DialogContainer {
|
||||
public readonly title = input.required<string>();
|
||||
}
|
||||
@ -12,8 +12,3 @@
|
||||
fa-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
(change)="onFileChange($event)"
|
||||
style="display: none"
|
||||
[multiple]="mode() === 'multiple'"
|
||||
/>
|
||||
<input #fileInput type="file" (change)="onFileChange($event)" style="display: none" />
|
||||
<div class="file-input-container">
|
||||
<button type="button">
|
||||
<fa-icon [icon]="iconDefinition()" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
|
||||
</button>
|
||||
<fa-icon [icon]="faFileUpload" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
|
||||
@if (fileNames().length) {
|
||||
@for (fileName of fileNames(); track $index) {
|
||||
<p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { FaIconComponent, IconDefinition } from '@fortawesome/angular-fontawesome';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { faCancel, faFileUpload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FileUploadEvent } from './FileUploadEvent';
|
||||
|
||||
@ -11,8 +11,6 @@ import { FileUploadEvent } from './FileUploadEvent';
|
||||
})
|
||||
export class FileUpload {
|
||||
public readonly files = input<File[]>([]);
|
||||
public readonly mode = input<'single' | 'multiple'>('single');
|
||||
public readonly iconDefinition = input<IconDefinition>(faFileUpload);
|
||||
public readonly fileChange = output<FileUploadEvent>();
|
||||
|
||||
protected fileNames = computed(() => this.files().map((file) => file.name));
|
||||
@ -39,5 +37,6 @@ export class FileUpload {
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
protected readonly faFileUpload = faFileUpload;
|
||||
protected readonly faCancel = faCancel;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<h2>Nav</h2>
|
||||
<ul>
|
||||
@for (link of links(); track link.relativeUrl) {
|
||||
@if (!link.disabled) {
|
||||
<li>
|
||||
<a [routerLink]="link.relativeUrl">{{ link.title }}</a>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
<li><a [routerLink]="'/'">Browse Recipes</a></li>
|
||||
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
|
||||
<li><a [routerLink]="'/recipe-upload'">Upload Recipe</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { NavLinkConfig } from './NavLinkConfig';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav',
|
||||
@ -9,24 +7,4 @@ import { NavLinkConfig } from './NavLinkConfig';
|
||||
templateUrl: './nav.html',
|
||||
styleUrl: './nav.css',
|
||||
})
|
||||
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
export class Nav {}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RecipeCard } from './recipe-card/recipe-card';
|
||||
import { RecipeInfoView } from '../../models/Recipe.model';
|
||||
import { Recipe } from '../../models/Recipe.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-card-grid',
|
||||
@ -9,5 +9,5 @@ import { RecipeInfoView } from '../../models/Recipe.model';
|
||||
styleUrl: './recipe-card-grid.css',
|
||||
})
|
||||
export class RecipeCardGrid {
|
||||
public readonly recipes = input.required<RecipeInfoView[]>();
|
||||
public readonly recipes = input.required<Recipe[]>();
|
||||
}
|
||||
|
||||
@ -4,15 +4,6 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recipe-card-image-placeholder {
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
@let recipe = this.recipe();
|
||||
<article>
|
||||
<a [routerLink]="recipePageLink()">
|
||||
@if (loadingMainImage()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadMainImageError()) {
|
||||
<p>Error</p>
|
||||
} @else if (mainImage()) {
|
||||
<img [src]="mainImage()" id="recipe-card-image" [alt]="recipe.mainImage!.alt" />
|
||||
} @else {
|
||||
<div class="recipe-card-image-placeholder">
|
||||
<app-logo></app-logo>
|
||||
</div>
|
||||
@if (mainImage.isSuccess()) {
|
||||
<img [src]="mainImage.data()" id="recipe-card-image" [alt]="recipe.mainImage.alt" />
|
||||
}
|
||||
</a>
|
||||
<div id="title-and-visibility">
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
|
||||
import { RecipeInfoView } from '../../../models/Recipe.model';
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { Recipe } from '../../../models/Recipe.model';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
||||
import { ImageService } from '../../../services/ImageService';
|
||||
import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Logo } from '../../logo/logo';
|
||||
import { Spinner } from '../../spinner/spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-card',
|
||||
imports: [RouterLink, FaIconComponent, Logo, Spinner],
|
||||
imports: [RouterLink, FaIconComponent],
|
||||
templateUrl: './recipe-card.html',
|
||||
styleUrl: './recipe-card.css',
|
||||
})
|
||||
export class RecipeCard implements OnInit {
|
||||
public recipe = input.required<RecipeInfoView>();
|
||||
export class RecipeCard {
|
||||
public recipe = input.required<Recipe>();
|
||||
|
||||
protected readonly recipePageLink = computed(() => {
|
||||
const recipe = this.recipe();
|
||||
@ -28,25 +27,11 @@ export class RecipeCard implements OnInit {
|
||||
|
||||
private readonly imageService = inject(ImageService);
|
||||
|
||||
protected readonly loadingMainImage = signal(false);
|
||||
protected readonly loadMainImageError = signal<Error | null>(null);
|
||||
protected readonly mainImage = signal<string | null>(null);
|
||||
|
||||
public ngOnInit(): void {
|
||||
protected readonly mainImage = injectQuery(() => {
|
||||
const recipe = this.recipe();
|
||||
if (recipe.mainImage) {
|
||||
this.loadingMainImage.set(true);
|
||||
this.imageService.getImageBlobUrl(recipe.mainImage.owner.username, recipe.mainImage.filename).subscribe({
|
||||
next: (blobUrl) => {
|
||||
this.loadingMainImage.set(false);
|
||||
this.mainImage.set(blobUrl);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingMainImage.set(false);
|
||||
this.loadMainImageError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
return {
|
||||
queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
|
||||
queryFn: () => this.imageService.getImage(recipe.mainImage.url),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,40 +11,41 @@
|
||||
} @else {
|
||||
<p>You must be logged in to comment.</p>
|
||||
}
|
||||
<p>Showing {{ loadedCommentsCount() }} of {{ totalComments() }} comments.</p>
|
||||
<h3>Comments</h3>
|
||||
@if (commentsQuery.isPending()) {
|
||||
<p>Loading comments...</p>
|
||||
} @else if (commentsQuery.isError()) {
|
||||
<p>There was an error loading the comments.</p>
|
||||
} @else {
|
||||
<ul id="comments">
|
||||
@if (submittingComment()) {
|
||||
@if (addCommentMutation.isPending()) {
|
||||
<li class="comment" style="opacity: 0.5">
|
||||
<div class="comment-username-time">
|
||||
<span class="comment-username">{{ username() }}</span>
|
||||
</div>
|
||||
<div>{{ submittedComment() }}</div>
|
||||
<app-spinner size="12px"></app-spinner>
|
||||
<div>{{ addCommentMutation.variables() }}</div>
|
||||
</li>
|
||||
}
|
||||
@for (commentsSlice of commentsSlices(); track $index) {
|
||||
@for (comment of commentsSlice.content; track $index) {
|
||||
@for (recipeComments of commentsQuery.data()?.pages; track $index) {
|
||||
@for (recipeComment of recipeComments.content; track recipeComment.id) {
|
||||
<li class="comment">
|
||||
<div class="comment-username-time">
|
||||
<span class="comment-username">{{ comment.owner.username }}</span>
|
||||
<span class="comment-time">{{ comment.created | dateTimeFormat }}</span>
|
||||
<span class="comment-username">{{ recipeComment.owner.username }}</span>
|
||||
<span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span>
|
||||
</div>
|
||||
<div class="comment-text" [innerHTML]="comment.text"></div>
|
||||
<div class="comment-text" [innerHTML]="recipeComment.text"></div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@if (loadingComments()) {
|
||||
<app-spinner></app-spinner>
|
||||
}
|
||||
@if (loadCommentsError()) {
|
||||
<p>There was an error loading comments.</p>
|
||||
}
|
||||
</ul>
|
||||
<div>
|
||||
@if (hasNextSlice()) {
|
||||
<button (click)="loadMoreComments()">Load more comments</button>
|
||||
} @else {
|
||||
<p>No more comments.</p>
|
||||
@if (commentsQuery.hasNextPage() && !commentsQuery.isFetchingNextPage()) {
|
||||
<button (click)="commentsQuery.fetchNextPage()">Load more comments</button>
|
||||
} @else if (commentsQuery.isFetchingNextPage()) {
|
||||
<p>Loading comments...</p>
|
||||
} @else if (!commentsQuery.hasNextPage()) {
|
||||
<p>No additional comments to load.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { RecipeService } from '../../services/RecipeService';
|
||||
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { RecipeComments } from '../../models/RecipeComment.model';
|
||||
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
|
||||
import { SliceView } from '../../models/SliceView.model';
|
||||
import { RecipeComment } from '../../models/RecipeComment.model';
|
||||
import { Spinner } from '../spinner/spinner';
|
||||
import { range, switchMap, toArray } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-comments-list',
|
||||
imports: [ReactiveFormsModule, DateTimeFormatPipe, Spinner],
|
||||
imports: [ReactiveFormsModule, DateTimeFormatPipe],
|
||||
templateUrl: './recipe-comments-list.html',
|
||||
styleUrl: './recipe-comments-list.css',
|
||||
})
|
||||
export class RecipeCommentsList implements OnInit {
|
||||
export class RecipeCommentsList {
|
||||
public readonly recipeUsername = input.required<string>();
|
||||
public readonly recipeSlug = input.required<string>();
|
||||
|
||||
@ -24,133 +22,35 @@ export class RecipeCommentsList implements OnInit {
|
||||
protected readonly username = this.authService.username;
|
||||
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
|
||||
|
||||
protected readonly totalComments = signal<number>(0);
|
||||
|
||||
protected readonly loadingComments = signal(false);
|
||||
protected readonly loadCommentsError = signal<Error | null>(null);
|
||||
protected readonly commentsSlices = signal<SliceView<RecipeComment>[]>([]);
|
||||
protected readonly hasNextSlice = computed(() =>
|
||||
this.commentsSlices().length ? this.commentsSlices()[this.commentsSlices().length - 1].slice.hasNext : null,
|
||||
);
|
||||
protected readonly loadedCommentsCount = computed(() =>
|
||||
this.commentsSlices().reduce((acc, slice) => acc + slice.content.length, 0),
|
||||
);
|
||||
protected commentsQuery = injectInfiniteQuery(() => ({
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (previousPage: RecipeComments) =>
|
||||
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
|
||||
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
|
||||
queryFn: ({ pageParam }) => {
|
||||
return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), {
|
||||
page: pageParam,
|
||||
size: 10,
|
||||
sort: [
|
||||
{
|
||||
property: 'created',
|
||||
order: 'DESC',
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
protected readonly addCommentForm = new FormGroup({
|
||||
comment: new FormControl('', Validators.required),
|
||||
});
|
||||
|
||||
protected readonly submittingComment = signal(false);
|
||||
protected readonly submitCommentError = signal<Error | null>(null);
|
||||
protected readonly submittedComment = signal<string | null>('');
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.loadingComments.set(true);
|
||||
this.loadCommentCount();
|
||||
this.recipeService
|
||||
.getComments(this.recipeUsername(), this.recipeSlug(), {
|
||||
page: 0,
|
||||
size: 10,
|
||||
sort: [
|
||||
{
|
||||
property: 'created',
|
||||
order: 'DESC',
|
||||
},
|
||||
],
|
||||
})
|
||||
.subscribe({
|
||||
next: (commentsSlice) => {
|
||||
this.loadingComments.set(false);
|
||||
this.commentsSlices.update((old) => [...old, commentsSlice]);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingComments.set(false);
|
||||
this.loadCommentsError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadCommentCount(): void {
|
||||
this.recipeService.getCommentsCount(this.recipeUsername(), this.recipeSlug()).subscribe({
|
||||
next: (count) => {
|
||||
this.totalComments.set(count);
|
||||
},
|
||||
error: (e) => {
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected loadMoreComments(): void {
|
||||
if (this.hasNextSlice()) {
|
||||
this.loadingComments.set(true);
|
||||
this.recipeService
|
||||
.getComments(this.recipeUsername(), this.recipeSlug(), {
|
||||
page: this.commentsSlices().length,
|
||||
size: 10,
|
||||
sort: [
|
||||
{
|
||||
property: 'created',
|
||||
order: 'DESC',
|
||||
},
|
||||
],
|
||||
})
|
||||
.subscribe({
|
||||
next: (nextSlice) => {
|
||||
this.loadingComments.set(false);
|
||||
this.commentsSlices.update((prev) => [...prev, nextSlice]);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingComments.set(false);
|
||||
this.loadCommentsError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private reloadComments(): void {
|
||||
this.loadCommentCount();
|
||||
range(0, this.commentsSlices().length)
|
||||
.pipe(
|
||||
switchMap((page) => {
|
||||
return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), {
|
||||
page,
|
||||
size: 10,
|
||||
sort: [
|
||||
{
|
||||
property: 'created',
|
||||
order: 'DESC',
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
toArray(),
|
||||
)
|
||||
.subscribe({
|
||||
next: (slices) => {
|
||||
this.commentsSlices.set(slices);
|
||||
},
|
||||
error: (e) => {
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
protected readonly addCommentMutation = injectMutation(() => ({
|
||||
mutationFn: (commentText: string) =>
|
||||
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText),
|
||||
}));
|
||||
|
||||
protected onCommentSubmit() {
|
||||
const comment = this.addCommentForm.value.comment!;
|
||||
this.submittingComment.set(true);
|
||||
this.submittedComment.set(comment);
|
||||
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), comment).subscribe({
|
||||
next: () => {
|
||||
this.submittingComment.set(false);
|
||||
this.reloadComments();
|
||||
},
|
||||
error: (e) => {
|
||||
this.submitCommentError.set(e);
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
this.addCommentMutation.mutate(this.addCommentForm.value.comment!);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { IngredientDraft } from '../../models/RecipeDraftView.model';
|
||||
import { ImageView } from '../../models/ImageView.model';
|
||||
|
||||
export interface RecipeEditFormSubmitEvent {
|
||||
title: string;
|
||||
slug: string;
|
||||
ingredients: IngredientDraft[];
|
||||
preparationTime: number | null;
|
||||
cookingTime: number | null;
|
||||
totalTime: number | null;
|
||||
mainImage: ImageView | null;
|
||||
rawText: string;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<app-dialog-container title="Edit Image">
|
||||
@if (loading()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadError()) {
|
||||
<p>There was an error.</p>
|
||||
} @else {
|
||||
<form (submit)="onSubmit($event)">
|
||||
<mat-form-field>
|
||||
<mat-label>Filename</mat-label>
|
||||
<input matInput [formControl]="imageForm.controls.filename" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Alt</mat-label>
|
||||
<input matInput [formControl]="imageForm.controls.alt" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Caption</mat-label>
|
||||
<input matInput [formControl]="imageForm.controls.caption" />
|
||||
</mat-form-field>
|
||||
<button type="submit" matButton="filled">Submit</button>
|
||||
@if (submitting()) {
|
||||
<app-spinner size="24px"></app-spinner>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</app-dialog-container>
|
||||
@ -1,68 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { EditImageDialog } from './edit-image-dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Mocked } from 'vitest';
|
||||
import { ImageService } from '../../../../services/ImageService';
|
||||
import { ImageView } from '../../../../models/ImageView.model';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('EditImageDialog', () => {
|
||||
let component: EditImageDialog;
|
||||
let fixture: ComponentFixture<EditImageDialog>;
|
||||
let matDialogRef: Partial<Mocked<MatDialogRef<any>>>;
|
||||
|
||||
const username = 'test-user';
|
||||
const filename = 'test-file.jpg';
|
||||
|
||||
beforeEach(async () => {
|
||||
matDialogRef = {
|
||||
close: vi.fn(),
|
||||
} as Partial<Mocked<MatDialogRef<any>>>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EditImageDialog],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: { username, filename },
|
||||
},
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: matDialogRef,
|
||||
},
|
||||
{
|
||||
provide: ImageService,
|
||||
useValue: {
|
||||
getImageView2: vi.fn((username, filename) =>
|
||||
of({
|
||||
filename,
|
||||
owner: {
|
||||
username,
|
||||
},
|
||||
} as ImageView),
|
||||
),
|
||||
updateImage: vi.fn(() => of({} as ImageView)),
|
||||
} as Partial<Mocked<ImageService>>,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EditImageDialog);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should close dialog after successful submit', async () => {
|
||||
const formDebug = fixture.debugElement.query(By.css('form'));
|
||||
expect(formDebug).toBeTruthy();
|
||||
const formElement = formDebug.nativeElement as HTMLFormElement;
|
||||
formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
expect(matDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,84 +0,0 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { DialogContainer } from '../../../dialog-container/dialog-container';
|
||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ImageView } from '../../../../models/ImageView.model';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { ImageService } from '../../../../services/ImageService';
|
||||
import { notNullOrUndefined } from '../../../../util';
|
||||
import { Spinner } from '../../../spinner/spinner';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-image-dialog',
|
||||
imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton, Spinner],
|
||||
templateUrl: './edit-image-dialog.html',
|
||||
styleUrl: './edit-image-dialog.css',
|
||||
})
|
||||
export class EditImageDialog implements OnInit {
|
||||
private readonly usernameFilename: [username: string, filename: string] = inject(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef);
|
||||
private readonly imageService = inject(ImageService);
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loadError = signal<Error | null>(null);
|
||||
protected readonly imageView = signal<ImageView | null>(null);
|
||||
|
||||
protected readonly submitting = signal(false);
|
||||
protected readonly submitError = signal<Error | null>(null);
|
||||
|
||||
protected readonly imageForm = new FormGroup({
|
||||
filename: new FormControl(
|
||||
{
|
||||
value: '',
|
||||
disabled: true,
|
||||
},
|
||||
Validators.required,
|
||||
),
|
||||
alt: new FormControl(''),
|
||||
caption: new FormControl(''),
|
||||
});
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.loading.set(true);
|
||||
this.imageService.getImageView2(this.usernameFilename[0], this.usernameFilename[1]).subscribe({
|
||||
next: (imageView) => {
|
||||
this.loading.set(false); // shouldn't need this
|
||||
this.imageView.set(imageView);
|
||||
this.imageForm.patchValue({
|
||||
filename: imageView.filename,
|
||||
alt: imageView.alt,
|
||||
caption: imageView.caption,
|
||||
});
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading.set(false);
|
||||
this.loadError.set(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async onSubmit(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const imageView = this.imageView()!;
|
||||
const formValue = this.imageForm.value;
|
||||
if (notNullOrUndefined(formValue.alt)) {
|
||||
imageView.alt = formValue.alt;
|
||||
}
|
||||
if (notNullOrUndefined(formValue.caption)) {
|
||||
imageView.caption = formValue.caption;
|
||||
}
|
||||
this.submitting.set(true);
|
||||
this.imageService.updateImage(imageView.owner.username, imageView.filename, imageView).subscribe({
|
||||
next: (imageView) => {
|
||||
this.submitting.set(false);
|
||||
this.imageView.set(imageView);
|
||||
this.dialogRef.close();
|
||||
},
|
||||
error: (e) => {
|
||||
this.submitting.set(false);
|
||||
this.submitError.set(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.image-grid-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-filename {
|
||||
font-size: 0.75em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
@if (loadingImages()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadImagesError()) {
|
||||
<p>There was an error loading images.</p>
|
||||
} @else {
|
||||
<div class="image-grid">
|
||||
@for (image of imagesSlice()!.content; track $index) {
|
||||
<mat-card>
|
||||
<img
|
||||
mat-card-image
|
||||
[src]="image.blobUrl"
|
||||
[alt]="image.alt"
|
||||
(click)="onImageClick(image)"
|
||||
class="image-grid-image"
|
||||
/>
|
||||
<mat-card-content>
|
||||
<p class="image-filename">{{ image.filename }}</p>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<mat-checkbox [checked]="isSelected(image)" (click)="onImageClick(image)">Main?</mat-checkbox>
|
||||
<button matButton="text" type="button" [matMenuTriggerFor]="imageActionsMenu">
|
||||
<fa-icon [icon]="faEllipsis"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #imageActionsMenu>
|
||||
<button mat-menu-item type="button" (click)="editImage(image)">Edit</button>
|
||||
<button mat-menu-item type="button" (click)="deleteImage(image)">Delete</button>
|
||||
</mat-menu>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (deleting()) {
|
||||
<app-spinner size="24px"></app-spinner>
|
||||
} @else if (deleteError()) {
|
||||
<p>There was an error deleting the image.</p>
|
||||
}
|
||||
|
||||
<mat-paginator
|
||||
[length]="imageCount()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageSizeOptions]="[3, 6, 9, 12]"
|
||||
(page)="onPage($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ImageSelect } from './image-select';
|
||||
import { Mocked } from 'vitest';
|
||||
import { ImageService } from '../../../services/ImageService';
|
||||
import { of } from 'rxjs';
|
||||
import { SliceView, SliceViewMeta } from '../../../models/SliceView.model';
|
||||
import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
|
||||
|
||||
describe('ImageSelect', () => {
|
||||
let component: ImageSelect;
|
||||
let fixture: ComponentFixture<ImageSelect>;
|
||||
|
||||
let imageService: Partial<Mocked<ImageService>> = {
|
||||
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
|
||||
of({
|
||||
content: [],
|
||||
slice: {} as SliceViewMeta,
|
||||
count: 0,
|
||||
} as SliceView<ImageViewWithBlobUrl>),
|
||||
),
|
||||
getOwnedImagesCount: vi.fn(() => of(0)),
|
||||
deleteImage: vi.fn(() => of()),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ImageSelect],
|
||||
providers: [
|
||||
{
|
||||
provide: ImageService,
|
||||
useValue: imageService,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ImageSelect);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,140 +0,0 @@
|
||||
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { ImageService } from '../../../services/ImageService';
|
||||
import { Spinner } from '../../spinner/spinner';
|
||||
import { ImageView } from '../../../models/ImageView.model';
|
||||
import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
|
||||
import { SliceView } from '../../../models/SliceView.model';
|
||||
import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
|
||||
|
||||
@Component({
|
||||
selector: 'app-image-select',
|
||||
imports: [
|
||||
MatPaginator,
|
||||
Spinner,
|
||||
MatCard,
|
||||
MatCardImage,
|
||||
MatCardContent,
|
||||
MatCardActions,
|
||||
MatCheckbox,
|
||||
MatMenuTrigger,
|
||||
MatMenu,
|
||||
FaIconComponent,
|
||||
MatButton,
|
||||
MatMenuItem,
|
||||
],
|
||||
templateUrl: './image-select.html',
|
||||
styleUrl: './image-select.css',
|
||||
})
|
||||
export class ImageSelect implements OnInit {
|
||||
public readonly select = output<ImageView | null>();
|
||||
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
|
||||
|
||||
protected readonly currentPage = signal(0);
|
||||
protected readonly pageSize = signal(9);
|
||||
|
||||
private readonly imageService = inject(ImageService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
protected readonly loadingImages = signal(false);
|
||||
protected readonly loadImagesError = signal<Error | null>(null);
|
||||
protected readonly imagesSlice = signal<SliceView<ImageViewWithBlobUrl> | null>(null);
|
||||
|
||||
protected readonly imageCount = signal(0);
|
||||
|
||||
protected readonly deleting = signal(false);
|
||||
protected readonly deleteError = signal<Error | null>(null);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.loadImages();
|
||||
}
|
||||
|
||||
private loadImages(): void {
|
||||
this.loadingImages.set(true);
|
||||
this.imageService
|
||||
.getOwnedImageViewsWithBlobUrls({
|
||||
page: this.currentPage(),
|
||||
size: this.pageSize(),
|
||||
sort: [
|
||||
{
|
||||
property: 'created',
|
||||
order: 'DESC',
|
||||
},
|
||||
],
|
||||
})
|
||||
.subscribe({
|
||||
next: (sliceView) => {
|
||||
sliceView.content.sort((a, b) => b.created.valueOf() - a.created.valueOf());
|
||||
this.loadingImages.set(false);
|
||||
this.imagesSlice.set(sliceView);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingImages.set(false);
|
||||
this.loadImagesError.set(e);
|
||||
},
|
||||
});
|
||||
this.imageService.getOwnedImagesCount().subscribe({
|
||||
next: (count) => {
|
||||
this.imageCount.set(count);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected onPage(pageEvent: PageEvent): void {
|
||||
if (pageEvent.pageIndex < this.currentPage()) {
|
||||
// backward
|
||||
this.currentPage.update((old) => Math.max(old - 1, 0));
|
||||
} else {
|
||||
// forward
|
||||
this.currentPage.update((old) => (this.imagesSlice()?.slice.hasNext ? old + 1 : old));
|
||||
}
|
||||
this.pageSize.set(pageEvent.pageSize);
|
||||
this.loadImages();
|
||||
}
|
||||
|
||||
protected onImageClick(imageView: ImageView): void {
|
||||
if (this.isSelected(imageView)) {
|
||||
this.select.emit(null);
|
||||
} else {
|
||||
this.select.emit(imageView);
|
||||
}
|
||||
}
|
||||
|
||||
protected isSelected(imageView: ImageView): boolean {
|
||||
const selectedUsernameFilename = this.selectedUsernameFilename();
|
||||
if (selectedUsernameFilename) {
|
||||
const [username, filename] = selectedUsernameFilename;
|
||||
return imageView.owner.username === username && imageView.filename === filename;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected editImage(imageView: ImageView): void {
|
||||
this.dialog.open(EditImageDialog, {
|
||||
data: [imageView.owner.username, imageView.filename],
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteImage(imageView: ImageView): void {
|
||||
this.deleting.set(true);
|
||||
this.imageService.deleteImage(imageView.owner.username, imageView.filename).subscribe({
|
||||
next: () => {
|
||||
this.deleting.set(false);
|
||||
this.loadImages();
|
||||
},
|
||||
error: (e) => {
|
||||
this.deleting.set(false);
|
||||
this.deleteError.set(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
.image-upload-dialog-content {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
row-gap: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button div {
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
<app-dialog-container title="Image Upload">
|
||||
<form>
|
||||
@let file = fileToUpload();
|
||||
@if (file !== null) {
|
||||
<app-file-upload
|
||||
[files]="[file]"
|
||||
(fileChange)="onImageFileEvent($event)"
|
||||
[iconDefinition]="faFileImage"
|
||||
></app-file-upload>
|
||||
} @else {
|
||||
<app-file-upload
|
||||
[files]="[]"
|
||||
(fileChange)="onImageFileEvent($event)"
|
||||
[iconDefinition]="faFileImage"
|
||||
></app-file-upload>
|
||||
}
|
||||
<mat-form-field>
|
||||
<mat-label>Filename</mat-label>
|
||||
<input matInput [formControl]="imageUploadForm.controls.filename" />
|
||||
@if (imageUploadForm.controls.filename.hasError("imageExists")) {
|
||||
<mat-error>Filename taken. Choose a different name.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Alt</mat-label>
|
||||
<input matInput [formControl]="imageUploadForm.controls.alt" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Caption</mat-label>
|
||||
<input matInput [formControl]="imageUploadForm.controls.caption" />
|
||||
</mat-form-field>
|
||||
<mat-checkbox [formControl]="imageUploadForm.controls.isPublic">Is Public?</mat-checkbox>
|
||||
@if (error()) {
|
||||
<p>{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
matButton="filled"
|
||||
(click)="onSubmit()"
|
||||
[disabled]="submitDisabled() || imageUploadForm.invalid"
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<span>Upload</span>
|
||||
<app-spinner [enabled]="inProgress()" size="24px"></app-spinner>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
</app-dialog-container>
|
||||
@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ImageUploadDialog } from './image-upload-dialog';
|
||||
|
||||
describe('ImageUploadDialog', () => {
|
||||
let component: ImageUploadDialog;
|
||||
let fixture: ComponentFixture<ImageUploadDialog>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ImageUploadDialog],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ImageUploadDialog);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,89 +0,0 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { ImageService } from '../../../services/ImageService';
|
||||
import { FileUpload } from '../../file-upload/file-upload';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { faFileImage } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FileUploadEvent } from '../../file-upload/FileUploadEvent';
|
||||
import { Spinner } from '../../spinner/spinner';
|
||||
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { ImageDoesNotExistValidator } from '../../../validators/image-does-not-exist-validator';
|
||||
import { DialogContainer } from '../../dialog-container/dialog-container';
|
||||
|
||||
@Component({
|
||||
selector: 'app-image-upload-dialog',
|
||||
imports: [
|
||||
FileUpload,
|
||||
MatButton,
|
||||
Spinner,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
ReactiveFormsModule,
|
||||
MatCheckbox,
|
||||
MatError,
|
||||
DialogContainer,
|
||||
],
|
||||
templateUrl: './image-upload-dialog.html',
|
||||
styleUrl: './image-upload-dialog.css',
|
||||
})
|
||||
export class ImageUploadDialog {
|
||||
public readonly dialogRef = inject(MatDialogRef);
|
||||
|
||||
protected readonly fileToUpload = signal<File | null>(null);
|
||||
protected readonly inProgress = signal(false);
|
||||
protected readonly submitDisabled = computed(() => !this.fileToUpload() || this.inProgress());
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
private readonly imageDoesNotExistValidator = inject(ImageDoesNotExistValidator);
|
||||
|
||||
protected readonly imageUploadForm = new FormGroup({
|
||||
filename: new FormControl('', {
|
||||
validators: Validators.required,
|
||||
asyncValidators: this.imageDoesNotExistValidator.validator(),
|
||||
updateOn: 'blur',
|
||||
}),
|
||||
alt: new FormControl(''),
|
||||
caption: new FormControl(''),
|
||||
isPublic: new FormControl(true),
|
||||
});
|
||||
|
||||
private readonly imageService = inject(ImageService);
|
||||
|
||||
protected onImageFileEvent(event: FileUploadEvent): void {
|
||||
if (event._tag === 'file-add-event') {
|
||||
this.fileToUpload.set(event.file);
|
||||
this.imageUploadForm.controls.filename.setValue(event.file.name);
|
||||
this.imageUploadForm.controls.filename.markAsTouched();
|
||||
} else {
|
||||
this.fileToUpload.set(null);
|
||||
this.imageUploadForm.reset();
|
||||
}
|
||||
}
|
||||
|
||||
protected async onSubmit(): Promise<void> {
|
||||
this.inProgress.set(true);
|
||||
const formValue = this.imageUploadForm.value;
|
||||
let hadError = false;
|
||||
try {
|
||||
await this.imageService.uploadImage(
|
||||
this.fileToUpload()!, // submit disabled if this is null
|
||||
formValue.filename ?? undefined,
|
||||
formValue.alt ?? undefined,
|
||||
formValue.caption ?? undefined,
|
||||
formValue.isPublic ?? undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
this.error.set('There was an error during upload. Try again.');
|
||||
hadError = true;
|
||||
}
|
||||
this.inProgress.set(false);
|
||||
if (!hadError) {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly faFileImage = faFileImage;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
<app-dialog-container title="Edit Ingredient">
|
||||
<form (submit)="onSubmit($event)">
|
||||
<mat-form-field>
|
||||
<mat-label>Amount</mat-label>
|
||||
<input matInput [formControl]="ingredientForm.controls.amount" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput [formControl]="ingredientForm.controls.name" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Notes</mat-label>
|
||||
<input matInput [formControl]="ingredientForm.controls.notes" />
|
||||
</mat-form-field>
|
||||
<button matButton="filled" type="submit" [disabled]="ingredientForm.invalid">Submit</button>
|
||||
</form>
|
||||
</app-dialog-container>
|
||||
@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { IngredientDialog } from './ingredient-dialog';
|
||||
|
||||
describe('IngredientDialog', () => {
|
||||
let component: IngredientDialog;
|
||||
let fixture: ComponentFixture<IngredientDialog>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IngredientDialog],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IngredientDialog);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,44 +0,0 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { DialogContainer } from '../../dialog-container/dialog-container';
|
||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { IngredientDraftClientModel } from '../../../client-models/IngredientDraftClientModel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ingredient-dialog',
|
||||
imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton],
|
||||
templateUrl: './ingredient-dialog.html',
|
||||
styleUrl: './ingredient-dialog.css',
|
||||
})
|
||||
export class IngredientDialog implements OnInit {
|
||||
public readonly dialogRef = inject(MatDialogRef);
|
||||
private readonly model = inject<IngredientDraftClientModel>(MAT_DIALOG_DATA);
|
||||
|
||||
protected readonly ingredientForm = new FormGroup({
|
||||
amount: new FormControl(''),
|
||||
name: new FormControl('', Validators.required),
|
||||
notes: new FormControl(''),
|
||||
});
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.ingredientForm.patchValue({
|
||||
amount: this.model.draft.amount,
|
||||
name: this.model.draft.name,
|
||||
notes: this.model.draft.notes,
|
||||
});
|
||||
}
|
||||
|
||||
protected onSubmit(event: SubmitEvent): void {
|
||||
event.preventDefault();
|
||||
this.dialogRef.close({
|
||||
...this.model,
|
||||
draft: {
|
||||
amount: this.ingredientForm.value.amount,
|
||||
name: this.ingredientForm.value.name,
|
||||
notes: this.ingredientForm.value.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 60ch;
|
||||
}
|
||||
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.ingredients-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.times-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.mat-column-reorder {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
<form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)">
|
||||
<h3>Basic Info</h3>
|
||||
<mat-form-field>
|
||||
<mat-label>Title</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.title" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Slug</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.slug" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="ingredients-container">
|
||||
<h3>Ingredients</h3>
|
||||
|
||||
<table
|
||||
#ingredientsTable
|
||||
mat-table
|
||||
[dataSource]="ingredientModels()"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="onIngredientDrop($event)"
|
||||
>
|
||||
<ng-container matColumnDef="reorder">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef>
|
||||
<fa-icon [icon]="faBars"></fa-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
{{ model.draft.amount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
{{ model.draft.name }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="notes">
|
||||
<th mat-header-cell *matHeaderCellDef>Notes</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
{{ model.draft.notes }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
<button matButton="text" [matMenuTriggerFor]="ingredientActionsMenu" type="button">
|
||||
<fa-icon [icon]="faEllipsis" size="2x"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #ingredientActionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="editIngredient(model)" type="button">Edit</button>
|
||||
<button mat-menu-item (click)="onIngredientDelete(model)" type="button">Delete</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="['reorder', 'amount', 'name', 'notes', 'actions']"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: ['reorder', 'amount', 'name', 'notes', 'actions']"
|
||||
cdkDrag
|
||||
[cdkDragData]="row"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
|
||||
</div>
|
||||
|
||||
<h3>Images</h3>
|
||||
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
|
||||
|
||||
<h4>Select Main Image</h4>
|
||||
<app-image-select
|
||||
(select)="onMainImageSelect($event)"
|
||||
[selectedUsernameFilename]="mainImageUsernameFilename()"
|
||||
></app-image-select>
|
||||
|
||||
<div class="times-container">
|
||||
<h3>Times</h3>
|
||||
<p>Enter all as number of minutes, <em>eg.</em> 45</p>
|
||||
<mat-form-field>
|
||||
<mat-label>Preparation Time (minutes)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControl]="recipeFormGroup.controls.preparationTime"
|
||||
data-test-role="preparation-time-input"
|
||||
/>
|
||||
@if (recipeFormGroup.controls.preparationTime.hasError("pattern")) {
|
||||
<mat-error data-test-role="preparation-time-error">Must be a valid number.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Cooking Time (minutes)</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.cookingTime" data-test-role="cooking-time-input" />
|
||||
@if (recipeFormGroup.controls.cookingTime.hasError("pattern")) {
|
||||
<mat-error data-test-role="cooking-time-error">Must be a valid number.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Total Time (minutes)</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.totalTime" data-test-role="total-time-input" />
|
||||
@if (recipeFormGroup.controls.totalTime.hasError("pattern")) {
|
||||
<mat-error data-test-role="total-time-error">Must be a valid number.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<h3>Recipe Text</h3>
|
||||
<mat-form-field>
|
||||
<mat-label>Recipe Text</mat-label>
|
||||
<textarea
|
||||
#recipeTextTextarea
|
||||
matInput
|
||||
[formControl]="recipeFormGroup.controls.text"
|
||||
(input)="onRecipeTextChange($event)"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Submit</button>
|
||||
</form>
|
||||
@ -1,106 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RecipeEditForm } from './recipe-edit-form';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { inputBinding } from '@angular/core';
|
||||
import { UserInfoView } from '../../models/UserInfoView.model';
|
||||
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
|
||||
import { ImageService } from '../../services/ImageService';
|
||||
import { of } from 'rxjs';
|
||||
import { SliceView, SliceViewMeta } from '../../models/SliceView.model';
|
||||
import { ImageViewWithBlobUrl } from '../../client-models/ImageViewWithBlobUrl';
|
||||
|
||||
describe('RecipeEditForm', () => {
|
||||
let component: RecipeEditForm;
|
||||
let fixture: ComponentFixture<RecipeEditForm>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const imageServiceMock = {
|
||||
getOwnedImagesCount: vi.fn(() => of(0)),
|
||||
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
|
||||
of({
|
||||
count: 0,
|
||||
slice: {} as SliceViewMeta,
|
||||
content: [],
|
||||
} as SliceView<ImageViewWithBlobUrl>),
|
||||
),
|
||||
} as Partial<ImageService>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecipeEditForm],
|
||||
providers: [
|
||||
{
|
||||
provide: ImageService,
|
||||
useValue: imageServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RecipeEditForm, {
|
||||
bindings: [
|
||||
inputBinding(
|
||||
'recipe',
|
||||
() =>
|
||||
({
|
||||
id: 'test-id',
|
||||
created: new Date(),
|
||||
state: 'ENTER_DATA',
|
||||
owner: {} as UserInfoView,
|
||||
}) satisfies RecipeDraftViewModel,
|
||||
),
|
||||
],
|
||||
});
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
const testTimeInput = (describeBlockName: string, inputRole: string, errorRole: string) => {
|
||||
describe(describeBlockName, () => {
|
||||
it('should accept a number input with no error presented', () => {
|
||||
const preparationTimeInputDebug = fixture.debugElement.query(By.css(`[data-test-role=${inputRole}]`));
|
||||
expect(preparationTimeInputDebug).toBeTruthy();
|
||||
const preparationTimeInput: HTMLInputElement = preparationTimeInputDebug.nativeElement;
|
||||
preparationTimeInput.value = '1234';
|
||||
preparationTimeInput.dispatchEvent(new Event('input'));
|
||||
preparationTimeInput.dispatchEvent(new Event('blur'));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not output an error if touched but no input', () => {
|
||||
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
|
||||
By.css(`[data-test-role=${inputRole}]`),
|
||||
).nativeElement;
|
||||
preparationTimeInput.dispatchEvent(new Event('blur'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display an error if non-number input', () => {
|
||||
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
|
||||
By.css(`[data-test-role=${inputRole}]`),
|
||||
).nativeElement;
|
||||
preparationTimeInput.value = 'abcd';
|
||||
preparationTimeInput.dispatchEvent(new Event('input'));
|
||||
preparationTimeInput.dispatchEvent(new Event('blur'));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const errorDebug = fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`));
|
||||
expect(errorDebug).toBeTruthy();
|
||||
expect(errorDebug.nativeElement.textContent).toContain('Must be a valid number.');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('time inputs', () => {
|
||||
testTimeInput('preparation time', 'preparation-time-input', 'preparation-time-error');
|
||||
testTimeInput('cooking time', 'cooking-time-input', 'cooking-time-error');
|
||||
testTimeInput('total time', 'total-time-input', 'total-time-error');
|
||||
});
|
||||
});
|
||||
@ -1,255 +0,0 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
input,
|
||||
OnInit,
|
||||
output,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ImageSelect } from './image-select/image-select';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import {
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable,
|
||||
} from '@angular/material/table';
|
||||
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
||||
import { IngredientDraftClientModel } from '../../client-models/IngredientDraftClientModel';
|
||||
import { ImageView } from '../../models/ImageView.model';
|
||||
import { IngredientDialog } from './ingredient-dialog/ingredient-dialog';
|
||||
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
|
||||
import { RecipeEditFormSubmitEvent } from './RecipeEditFormSubmitEvent';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog';
|
||||
import { FullRecipeView } from '../../models/Recipe.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-edit-form',
|
||||
imports: [
|
||||
CdkDrag,
|
||||
CdkDropList,
|
||||
FaIconComponent,
|
||||
ImageSelect,
|
||||
MatButton,
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatError,
|
||||
MatFormField,
|
||||
MatHeaderCell,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatMenu,
|
||||
MatMenuItem,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable,
|
||||
ReactiveFormsModule,
|
||||
MatMenuTrigger,
|
||||
MatHeaderCellDef,
|
||||
],
|
||||
templateUrl: './recipe-edit-form.html',
|
||||
styleUrl: './recipe-edit-form.css',
|
||||
})
|
||||
export class RecipeEditForm implements OnInit {
|
||||
public readonly recipe = input.required<RecipeDraftViewModel | FullRecipeView>();
|
||||
public readonly editSlugDisabled = input<boolean>(false);
|
||||
public readonly submitRecipe = output<RecipeEditFormSubmitEvent>();
|
||||
public readonly deleteDraft = output<void>();
|
||||
|
||||
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
||||
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
protected readonly recipeFormGroup = new FormGroup({
|
||||
title: new FormControl('', Validators.required),
|
||||
slug: new FormControl('', Validators.required),
|
||||
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||
text: new FormControl('', Validators.required),
|
||||
});
|
||||
|
||||
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
||||
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
||||
|
||||
private readonly mainImage = signal<ImageView | null>(null);
|
||||
protected readonly mainImageUsernameFilename = computed(() => {
|
||||
const mainImage = this.mainImage();
|
||||
if (mainImage) {
|
||||
return [mainImage.owner.username, mainImage.filename] as const;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
public ngOnInit(): void {
|
||||
if (this.editSlugDisabled()) {
|
||||
this.recipeFormGroup.controls.slug.disable();
|
||||
}
|
||||
const draft = this.recipe();
|
||||
this.recipeFormGroup.patchValue({
|
||||
title: draft.title ?? '',
|
||||
slug: draft.slug ?? '',
|
||||
preparationTime: draft.preparationTime?.toString() ?? '',
|
||||
cookingTime: draft.cookingTime?.toString() ?? '',
|
||||
totalTime: draft.totalTime?.toString() ?? '',
|
||||
text: draft.rawText ?? '',
|
||||
});
|
||||
if (draft.ingredients) {
|
||||
this.ingredientModels.set(
|
||||
draft.ingredients.map((ingredient, index) => ({
|
||||
id: index,
|
||||
draft: ingredient,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (draft.mainImage) {
|
||||
this.mainImage.set(draft.mainImage);
|
||||
}
|
||||
runInInjectionContext(this.injector, () => {
|
||||
afterNextRender({
|
||||
mixedReadWrite: () => {
|
||||
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
protected addIngredient(): void {
|
||||
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
||||
IngredientDialog,
|
||||
{
|
||||
data: {
|
||||
id: this.ingredientModels().length,
|
||||
draft: {
|
||||
name: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
dialogRef.afterClosed().subscribe((ingredientModel) => {
|
||||
if (ingredientModel) {
|
||||
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
|
||||
this.ingredientsTable()!.renderRows();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected editIngredient(model: IngredientDraftClientModel): void {
|
||||
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
||||
IngredientDialog,
|
||||
{
|
||||
data: model,
|
||||
},
|
||||
);
|
||||
dialogRef.afterClosed().subscribe((model) => {
|
||||
if (model) {
|
||||
this.ingredientModels.update((models) => {
|
||||
const updated: IngredientDraftClientModel[] = [...models];
|
||||
const target = updated.find((search) => search.id === model.id);
|
||||
if (!target) {
|
||||
throw new Error('Ingredient does not exist.');
|
||||
}
|
||||
target.draft = model.draft;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
|
||||
this.ingredientModels.update((ingredientModels) => {
|
||||
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
|
||||
updated.sort((model0, model1) => model0.id - model1.id);
|
||||
updated.forEach((model, index) => {
|
||||
model.id = index;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
|
||||
this.ingredientModels.update((ingredientModels) => {
|
||||
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
|
||||
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
|
||||
ingredientModels.forEach((model, index) => {
|
||||
model.id = index;
|
||||
});
|
||||
return ingredientModels;
|
||||
});
|
||||
this.ingredientsTable()!.renderRows();
|
||||
}
|
||||
|
||||
private getTime(s?: string | null): number | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
return parseInt(s);
|
||||
} catch (e) {
|
||||
console.error(`Should not have had a parse error because of form validators: ${e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected onSubmit(event: SubmitEvent): void {
|
||||
event.preventDefault();
|
||||
const value = this.recipeFormGroup.value;
|
||||
this.submitRecipe.emit({
|
||||
title: value.title!,
|
||||
slug: value.slug!,
|
||||
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
|
||||
preparationTime: this.getTime(value.preparationTime),
|
||||
cookingTime: this.getTime(value.cookingTime),
|
||||
totalTime: this.getTime(value.totalTime),
|
||||
mainImage: this.mainImage(),
|
||||
rawText: value.text!,
|
||||
});
|
||||
}
|
||||
|
||||
protected openImageUploadDialog(): void {
|
||||
this.dialog.open(ImageUploadDialog);
|
||||
}
|
||||
|
||||
protected onMainImageSelect(imageView: ImageView | null): void {
|
||||
this.mainImage.set(imageView);
|
||||
}
|
||||
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
protected readonly faBars = faBars;
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
@if (enabled()) {
|
||||
<span class="loader" [style]="{ width: size(), height: size() }"></span>
|
||||
<span class="loader"></span>
|
||||
}
|
||||
|
||||
@ -8,5 +8,4 @@ import { Component, input } from '@angular/core';
|
||||
})
|
||||
export class Spinner {
|
||||
public readonly enabled = input(true);
|
||||
public readonly size = input('48px');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -1,15 +1,10 @@
|
||||
import { UserInfoView } from './UserInfoView.model';
|
||||
import { ResourceOwner } from './ResourceOwner.model';
|
||||
|
||||
export interface ImageView {
|
||||
url: string;
|
||||
created: Date;
|
||||
modified?: Date | null;
|
||||
alt: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
alt?: string | null;
|
||||
caption?: string | null;
|
||||
owner: UserInfoView;
|
||||
isPublic?: boolean;
|
||||
height: number | null;
|
||||
owner: ResourceOwner;
|
||||
url: string;
|
||||
width: number | null;
|
||||
}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
export interface QueryParams<T extends readonly string[] = any> {
|
||||
export interface QueryParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: Array<string | Sort<T>>;
|
||||
custom?: Record<string, any>;
|
||||
sort?: Array<string | Sort>;
|
||||
}
|
||||
|
||||
export interface Sort<T extends readonly string[] = any> {
|
||||
property: T[number];
|
||||
export interface Sort {
|
||||
property: string;
|
||||
order?: 'ASC' | 'DESC';
|
||||
ignoreCase?: boolean;
|
||||
}
|
||||
|
||||
@ -1,48 +1,27 @@
|
||||
import { UserInfoView } from './UserInfoView.model';
|
||||
import { ResourceOwner } from './ResourceOwner.model';
|
||||
import { ImageView } from './ImageView.model';
|
||||
|
||||
export interface RecipeInfoView {
|
||||
id: number;
|
||||
created: Date;
|
||||
modified?: Date | null;
|
||||
slug: string;
|
||||
title: string;
|
||||
preparationTime?: number | null;
|
||||
cookingTime?: number | null;
|
||||
totalTime?: number | null;
|
||||
owner: UserInfoView;
|
||||
isPublic: boolean;
|
||||
starCount: number;
|
||||
mainImage?: ImageView | null;
|
||||
export interface RecipeInfoViews {
|
||||
slice: {
|
||||
number: number;
|
||||
size: number;
|
||||
};
|
||||
content: Recipe[];
|
||||
}
|
||||
|
||||
export interface FullRecipeViewWrapper {
|
||||
export interface RecipeView {
|
||||
isOwner: boolean | null;
|
||||
isStarred: boolean | null;
|
||||
recipe: FullRecipeView;
|
||||
recipe: Recipe;
|
||||
}
|
||||
|
||||
export interface FullRecipeView {
|
||||
export interface Recipe {
|
||||
id: number;
|
||||
created: Date;
|
||||
modified?: Date | null;
|
||||
slug: string;
|
||||
title: string;
|
||||
preparationTime?: number | null;
|
||||
cookingTime?: number | null;
|
||||
totalTime?: number | null;
|
||||
ingredients?: Ingredient[];
|
||||
text: string;
|
||||
rawText?: string | null;
|
||||
owner: UserInfoView;
|
||||
starCount: number;
|
||||
viewerCount: number;
|
||||
mainImage?: ImageView | null;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface Ingredient {
|
||||
amount?: string | null;
|
||||
name: string;
|
||||
notes?: string | null;
|
||||
mainImage: ImageView;
|
||||
owner: ResourceOwner;
|
||||
slug: string;
|
||||
starCount: number;
|
||||
text: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { UserInfoView } from './UserInfoView.model';
|
||||
import { ResourceOwner } from './ResourceOwner.model';
|
||||
import { SliceView } from './SliceView.model';
|
||||
|
||||
export interface RecipeComments {
|
||||
slice: SliceView;
|
||||
content: RecipeComment[];
|
||||
}
|
||||
|
||||
export interface RecipeComment {
|
||||
id: number;
|
||||
@ -6,6 +12,6 @@ export interface RecipeComment {
|
||||
modified: string | null;
|
||||
text: string;
|
||||
rawText: string | null;
|
||||
owner: UserInfoView;
|
||||
owner: ResourceOwner;
|
||||
recipeId: number;
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
import { UserInfoView } from './UserInfoView.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: UserInfoView;
|
||||
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;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
export interface UserInfoView {
|
||||
export interface ResourceOwner {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export interface SetImageBody {
|
||||
username: string;
|
||||
userFilename: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user