Compare commits

..

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

113 changed files with 671 additions and 3159 deletions

View File

@ -25,7 +25,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/material-theme.scss", "src/styles.css", "src/style-imports.scss"] "styles": ["src/material-theme.scss", "src/styles.css"]
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -46,13 +46,7 @@
"development": { "development": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true, "sourceMap": true
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"

57
package-lock.json generated
View File

@ -18,7 +18,8 @@
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"ngx-toastr": "^20.0.5", "@tanstack/angular-query-experimental": "^5.90.16",
"ngx-sse-client": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@ -3976,6 +3977,47 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@ -6731,18 +6773,17 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ngx-toastr": { "node_modules/ngx-sse-client": {
"version": "20.0.5", "version": "20.0.1",
"resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-20.0.5.tgz", "resolved": "https://registry.npmjs.org/ngx-sse-client/-/ngx-sse-client-20.0.1.tgz",
"integrity": "sha512-JcGu1Cbl+0SovPhxma72ygGeZHtpHWWKwBCyiabb+MSWYtXu/SOwEZ2HTWtZ4wcEYOOiy9tDQZgiEKWXpibpRw==", "integrity": "sha512-OSFRirL5beveGj4An3lOzWwg/JZWJG4Q1TdbyW7lqSDacfwINpIjSHdWlpiQwIghKU7BtLAc6TonUGlU4MzGTQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "^21.0.0", "@angular/common": ">=20.0.0",
"@angular/core": "^21.0.0", "@angular/core": ">=20.0.0"
"rxjs": "^7.8.2"
} }
}, },
"node_modules/node-addon-api": { "node_modules/node-addon-api": {

View File

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

View File

@ -1,15 +1,17 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './shared/interceptors/auth.interceptor'; 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 = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])), provideHttpClient(withInterceptors([authInterceptor])),
provideToastr(), provideTanStackQuery(new QueryClient(), withDevtools()),
], ],
}; };

View File

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

View File

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

View File

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

View File

@ -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();
});
});

View File

@ -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');
},
});
}
}

View File

@ -26,36 +26,3 @@ article {
width: 100%; width: 100%;
object-fit: cover; 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;
}

View File

@ -3,24 +3,12 @@
<div id="recipe-header"> <div id="recipe-header">
<div> <div>
<h1>{{ recipe.title }}</h1> <h1>{{ recipe.title }}</h1>
<div class="recipe-actions">
@if (isLoggedIn()) { @if (isLoggedIn()) {
<button <button id="star" matButton="filled" (click)="starMutation.mutate()">
id="star"
[matButton]="recipeView().isStarred ? 'filled' : 'outlined'"
(click)="onToggleStar()"
>
<div id="star-label"> <div id="star-label">
<fa-icon [icon]="faStar" /> <fa-icon [icon]="faStar" />
@if (recipeView().isStarred) {
<span>Starred</span>
} @else {
<span>Star</span> <span>Star</span>
}
<span id="star-count">{{ recipe.starCount }}</span> <span id="star-count">{{ recipe.starCount }}</span>
@if (togglingStar()) {
<app-spinner size="12px"></app-spinner>
}
</div> </div>
</button> </button>
} @else { } @else {
@ -29,16 +17,6 @@
<span id="star-count">{{ recipe.starCount }}</span> <span id="star-count">{{ recipe.starCount }}</span>
</div> </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> <div>
<div> <div>
@ -52,56 +30,15 @@
} }
</div> </div>
</div> </div>
@if (loadingMainImage()) { @if (mainImageUrl.isSuccess()) {
<app-spinner></app-spinner>
} @else if (loadMainImageError()) {
<p>There was an error loading the main image.</p>
} @else if (mainImage()) {
<img <img
id="main-image" id="main-image"
[src]="mainImage()" [src]="mainImageUrl.data()"
[alt]="recipe.mainImage!.alt" [alt]="recipe.mainImage.alt"
[height]="recipe.mainImage!.height" [height]="recipe.mainImage.height"
[width]="recipe.mainImage!.width" [width]="recipe.mainImage.width"
/> />
} }
@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 [innerHTML]="recipe.text"></div>
</div>
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" /> <app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />
</article> </article>

View File

@ -1,112 +1,43 @@
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FullRecipeViewWrapper } from '../../../shared/models/Recipe.model'; import { RecipeView } from '../../../shared/models/Recipe.model';
import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../shared/services/ImageService'; 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecipeService } from '../../../shared/services/RecipeService'; import { RecipeService } from '../../../shared/services/RecipeService';
import { AuthService } from '../../../shared/services/AuthService'; import { AuthService } from '../../../shared/services/AuthService';
import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list'; import { RecipeCommentsList } from '../../../shared/components/recipe-comments-list/recipe-comments-list';
import { MatButton } from '@angular/material/button'; 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({ @Component({
selector: 'app-recipe-page-content', selector: 'app-recipe-page-content',
imports: [FaIconComponent, RecipeCommentsList, MatButton, Spinner, MatMenuTrigger, MatMenu, MatMenuItem], imports: [FaIconComponent, RecipeCommentsList, MatButton],
templateUrl: './recipe-page-content.html', templateUrl: './recipe-page-content.html',
styleUrl: './recipe-page-content.css', styleUrl: './recipe-page-content.css',
}) })
export class RecipePageContent implements OnInit { export class RecipePageContent {
public readonly recipeView = input.required<FullRecipeViewWrapper>(); public recipeView = input.required<RecipeView>();
public readonly requireHotReload = output<void>();
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
private readonly recipeService = inject(RecipeService); private readonly recipeService = inject(RecipeService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly router = inject(Router);
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected readonly isOwner = computed(() => !!this.recipeView().isOwner);
protected readonly loadingMainImage = signal(false); protected readonly mainImageUrl = injectQuery(() => {
protected readonly loadMainImageError = signal<Error | null>(null);
protected readonly mainImage = signal<string | null>(null);
protected readonly hasTiming = computed(() => {
const recipe = this.recipeView().recipe; 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); protected readonly starMutation = injectMutation(() => ({
mutationFn: () => this.recipeService.toggleStar(this.recipeView()),
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 faStar = faStar; protected readonly faStar = faStar;
protected readonly faUser = faUser; protected readonly faUser = faUser;
protected readonly faGlobe = faGlobe; protected readonly faGlobe = faGlobe;
protected readonly faLock = faLock; protected readonly faLock = faLock;
protected readonly faEllipsis = faEllipsis;
} }

View File

@ -1,7 +1,9 @@
@if (loadingRecipe()) { @if (recipeView.isLoading()) {
<app-spinner></app-spinner> <p>Loading...</p>
} @else if (loadRecipeError()) { } @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> <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>
} }

View File

@ -1,50 +1,23 @@
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { RecipeService } from '../../shared/services/RecipeService'; import { RecipeService } from '../../shared/services/RecipeService';
import { RecipePageContent } from './recipe-page-content/recipe-page-content'; import { RecipePageContent } from './recipe-page-content/recipe-page-content';
import { FullRecipeViewWrapper } from '../../shared/models/Recipe.model'; import { injectQuery } from '@tanstack/angular-query-experimental';
import { Spinner } from '../../shared/components/spinner/spinner';
@Component({ @Component({
selector: 'app-recipe-page', selector: 'app-recipe-page',
imports: [RecipePageContent, Spinner], imports: [RecipePageContent],
templateUrl: './recipe-page.html', templateUrl: './recipe-page.html',
styleUrl: './recipe-page.css', styleUrl: './recipe-page.css',
}) })
export class RecipePage implements OnInit { export class RecipePage {
private recipeService = inject(RecipeService); private recipeService = inject(RecipeService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private username = this.route.snapshot.paramMap.get('username') as string; private username = this.route.snapshot.paramMap.get('username') as string;
private slug = this.route.snapshot.paramMap.get('slug') as string; private slug = this.route.snapshot.paramMap.get('slug') as string;
protected readonly loadingRecipe = signal(false); protected recipeView = injectQuery(() => ({
protected readonly loadRecipeError = signal<Error | null>(null); queryKey: ['recipe', this.username, this.slug],
protected readonly recipe = signal<FullRecipeViewWrapper | null>(null); queryFn: () => this.recipeService.getRecipeView(this.username, this.slug),
}));
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);
},
});
}
} }

View File

@ -0,0 +1,51 @@
#recipe-upload-container {
display: flex;
flex-direction: column;
row-gap: 5px;
}
form {
display: flex;
width: 100%;
flex-direction: column;
row-gap: 5px;
}
#recipe-upload-form {
width: 66%;
}
.action-buttons-container {
width: 100%;
display: flex;
column-gap: 5px;
}
/*button {*/
/* width: 100%;*/
/*}*/
/*input[type="text"],*/
/*textarea {*/
/* padding: 10px;*/
/* font-size: 16px;*/
/*}*/
textarea {
box-sizing: border-box;
height: auto;
overflow: hidden;
resize: none;
}
#recipe-form-and-source {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 10px;
justify-items: flex-start;
}
#recipe-form-and-source div,
#recipe-form-and-source div img {
width: 100%;
}

View File

@ -1,3 +1,4 @@
<div id="recipe-upload-container">
<h1>Upload Recipe</h1> <h1>Upload Recipe</h1>
<app-recipe-upload-trail <app-recipe-upload-trail
[displayStep]="displayStep()" [displayStep]="displayStep()"
@ -15,11 +16,50 @@
} @else if (displayStep() === RecipeUploadStep.INFER) { } @else if (displayStep() === RecipeUploadStep.INFER) {
<app-infer></app-infer> <app-infer></app-infer>
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) { } @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
<app-enter-recipe-data <app-enter-recipe-data [model]="model()"></app-enter-recipe-data>
[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>
} }
<!--
<section>
<h2>Auto-Complete Recipe (Optional)</h2>
<p>Choose a photo of a recipe from your files, and AI will fill out the form below for you.</p>
<form id="recipe-upload-form" [formGroup]="recipeUploadForm" (ngSubmit)="onFileSubmit()">
<input id="file" type="file" (change)="onFileChange($event)" />
<div class="action-buttons-container">
<button matButton="outlined" type="button" (click)="onClear()">Clear</button>
<button matButton="filled" type="submit" [disabled]="!recipeUploadForm.valid || inferenceInProgress()">
AI Auto-Complete
</button>
<app-spinner [enabled]="inferenceInProgress()"></app-spinner>
</div>
</form>
</section>
<section>
<h2>Recipe Form</h2>
<div id="recipe-form-and-source">
<form [formGroup]="recipeForm" (ngSubmit)="onRecipeSubmit()">
<mat-form-field>
<mat-label>Recipe Title</mat-label>
<input matInput id="recipe-title" type="text" formControlName="title" />
</mat-form-field>
<mat-form-field>
<mat-label>Recipe Text</mat-label>
<textarea
matInput
id="recipe-text"
formControlName="recipeText"
(input)="onRecipeTextChange($event)"
></textarea>
</mat-form-field>
<button matButton="filled" type="submit" [disabled]="!recipeForm.valid || inferenceInProgress()">Add Recipe</button>
</form>
<div>
@if (sourceRecipeImage()) {
<img [src]="sourceRecipeImage()" alt="Your source recipe image." />
}
</div>
</div>
</section>
-->
</div>

View File

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

View File

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

View File

@ -34,12 +34,6 @@ export class RecipeUploadTrail {
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA, completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA, inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA,
}, },
{
index: RecipeUploadStep.REVIEW,
name: 'Review',
completed: this.inProgressStep() > RecipeUploadStep.REVIEW,
inProgress: this.inProgressStep() === RecipeUploadStep.REVIEW,
},
]; ];
if (this.includeInfer()) { if (this.includeInfer()) {
base.push({ base.push({

View File

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

View File

@ -1,40 +1,11 @@
<section> <section>
<h2>Start</h2> <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> <p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p>
<form id="ai-or-manual-form"> <form id="ai-or-manual-form">
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload> <app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
<div id="ai-or-manual-buttons"> <div id="ai-or-manual-buttons">
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button> <button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')"> <button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">Use AI Assist</button>
Use AI Assist
</button>
</div> </div>
</form> </form>
</section> </section>
</section>

View File

@ -1,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 { MatButton } from '@angular/material/button';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent'; import { AIOrManualSubmitEvent } from './AIOrManualSubmitEvent';
import { FileUpload } from '../../../../shared/components/file-upload/file-upload'; import { FileUpload } from '../../../../shared/components/file-upload/file-upload';
import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent'; import { FileUploadEvent } from '../../../../shared/components/file-upload/FileUploadEvent';
import { 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({ @Component({
selector: 'app-ai-or-manual', selector: 'app-ai-or-manual',
imports: [MatButton, ReactiveFormsModule, FileUpload, FaIconComponent, Spinner, DatePipe], imports: [MatButton, ReactiveFormsModule, FileUpload],
templateUrl: './ai-or-manual.html', templateUrl: './ai-or-manual.html',
styleUrl: './ai-or-manual.css', styleUrl: './ai-or-manual.css',
}) })
export class AiOrManual implements OnInit { export class AiOrManual {
public sourceFile = input.required<File | null>(); public sourceFile = input.required<File | null>();
public sourceFileChange = output<FileUploadEvent>(); public sourceFileChange = output<FileUploadEvent>();
public submitStep = output<AIOrManualSubmitEvent>(); public submitStep = output<AIOrManualSubmitEvent>();
private readonly recipeDraftService = inject(RecipeDraftService);
private readonly router = inject(Router);
private readonly toastrService = inject(ToastrService);
protected readonly sourceFilesArray = computed(() => { protected readonly sourceFilesArray = computed(() => {
const maybeSourceFile = this.sourceFile(); const maybeSourceFile = this.sourceFile();
if (maybeSourceFile) { 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) { protected onFileChange(event: FileUploadEvent) {
this.sourceFileChange.emit(event); this.sourceFileChange.emit(event);
} }
@ -62,14 +32,4 @@ export class AiOrManual implements OnInit {
protected onFormSubmit(mode: 'manual' | 'ai-assist') { protected onFormSubmit(mode: 'manual' | 'ai-assist') {
this.submitStep.emit({ mode }); this.submitStep.emit({ mode });
} }
protected async onInProgressDraftClick(draft: RecipeDraftViewModel) {
await this.router.navigate(['/recipe-upload'], {
queryParams: {
draftId: draft.id,
},
});
}
protected readonly faFilePen = faFilePen;
} }

View File

@ -1,9 +1,5 @@
.draft-info-container { form {
width: 60ch;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
} width: 60ch;
.draft-actions-button {
padding: 0;
} }

View File

@ -1,16 +1,15 @@
<h2>Enter Recipe</h2> <h2>Enter Recipe</h2>
<div class="draft-info-container"> <form [formGroup]="recipeFormGroup">
<div> <mat-form-field>
<p>Draft started: {{ draft().created | date: "short" }}</p> <mat-label>Title</mat-label>
<p>Last saved: {{ draft().modified | date: "short" }}</p> <input matInput [formControl]="recipeFormGroup.controls.title">
</div> </mat-form-field>
<div> <mat-form-field>
<button matButton="text" [matMenuTriggerFor]="draftActionsMenu" class="draft-actions-button"> <mat-label>Slug</mat-label>
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon> <input matInput [formControl]="recipeFormGroup.controls.slug">
</button> </mat-form-field>
<mat-menu #draftActionsMenu="matMenu"> <mat-form-field>
<button mat-menu-item (click)="onDraftDelete()">Delete draft</button> <mat-label>Recipe Text</mat-label>
</mat-menu> <textarea matInput [formControl]="recipeFormGroup.controls.text"></textarea>
</div> </mat-form-field>
</div> </form>
<app-recipe-edit-form [recipe]="draft()" (submitRecipe)="onSubmit($event)"></app-recipe-edit-form>

View File

@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EnterRecipeData } from './enter-recipe-data'; import { EnterRecipeData } from './enter-recipe-data';
import { inputBinding } from '@angular/core';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
describe('EnterRecipeData', () => { describe('EnterRecipeData', () => {
let component: EnterRecipeData; let component: EnterRecipeData;
@ -10,12 +9,9 @@ describe('EnterRecipeData', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [EnterRecipeData], imports: [EnterRecipeData],
providers: [],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(EnterRecipeData, { fixture = TestBed.createComponent(EnterRecipeData);
bindings: [inputBinding('draft', () => ({}) as RecipeDraftViewModel)],
});
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });

View File

@ -1,41 +1,29 @@
import { Component, input, output } from '@angular/core'; import { Component, input, OnInit } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms'; import { RecipeUploadModel } from '../../../../shared/client-models/RecipeUploadModel';
import { RecipeEditFormSubmitEvent } from '../../../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
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';
@Component({ @Component({
selector: 'app-enter-recipe-data', selector: 'app-enter-recipe-data',
imports: [ imports: [ReactiveFormsModule, MatFormField, MatLabel, MatInput],
ReactiveFormsModule,
MatButton,
MatMenuTrigger,
FaIconComponent,
MatMenu,
MatMenuItem,
RecipeEditForm,
DatePipe,
],
templateUrl: './enter-recipe-data.html', templateUrl: './enter-recipe-data.html',
styleUrl: './enter-recipe-data.css', styleUrl: './enter-recipe-data.css',
}) })
export class EnterRecipeData { export class EnterRecipeData implements OnInit {
public readonly draft = input.required<RecipeDraftViewModel>(); public readonly model = input.required<RecipeUploadModel>();
public readonly recipeSubmit = output<RecipeEditFormSubmitEvent>();
public readonly deleteDraft = output<void>();
protected onSubmit(event: RecipeEditFormSubmitEvent): void { public ngOnInit(): void {
this.recipeSubmit.emit(event); 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 { protected readonly recipeFormGroup = new FormGroup({
this.deleteDraft.emit(); title: new FormControl('', Validators.required),
} slug: new FormControl('', Validators.required),
text: new FormControl('', Validators.required),
protected readonly faEllipsis = faEllipsis; });
} }

View File

@ -1,9 +0,0 @@
<section>
<h2>Review and Publish</h2>
<p>Title: {{ draft().title }}</p>
<p>Slug: {{ draft().slug }}</p>
<div>
<p>Text: todo</p>
</div>
<button matButton="filled" (click)="onPublish()">Publish</button>
</section>

View File

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

View File

@ -1,18 +0,0 @@
import { Component, input, output } from '@angular/core';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
import { MatButton } from '@angular/material/button';
@Component({
selector: 'app-review',
imports: [MatButton],
templateUrl: './review.html',
styleUrl: './review.css',
})
export class Review {
public readonly draft = input.required<RecipeDraftViewModel>();
public readonly publish = output<void>();
protected onPublish(): void {
this.publish.emit();
}
}

View File

@ -1,15 +1,8 @@
<h1>Recipes</h1> <h1>Recipes</h1>
@if (loadingRecipes()) { @if (recipes.isSuccess()) {
<app-spinner></app-spinner> <app-recipe-card-grid [recipes]="recipes.data()" />
} @else if (loadRecipesError()) { } @else if (recipes.isLoading()) {
<p>There was an error loading recipes: {{ loadRecipesError() }}</p> <p>Loading...</p>
} @else { } @else if (recipes.isError()) {
<app-recipe-card-grid [recipes]="recipes()"></app-recipe-card-grid> <p>{{ recipes.error().message }}</p>
<mat-paginator
[length]="recipeCount()"
[pageIndex]="currentPage()"
[pageSize]="pageSize()"
[pageSizeOptions]="[5, 10, 25, 50]"
(page)="onPage($event)"
></mat-paginator>
} }

View File

@ -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 { RecipeService } from '../../shared/services/RecipeService';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid'; 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({ @Component({
selector: 'app-recipes-page', selector: 'app-recipes-page',
imports: [RecipeCardGrid, Spinner, MatPaginator], imports: [RecipeCardGrid],
templateUrl: './recipes-page.html', templateUrl: './recipes-page.html',
styleUrl: './recipes-page.css', styleUrl: './recipes-page.css',
}) })
export class RecipesPage implements OnInit { export class RecipesPage {
private readonly recipeService = inject(RecipeService); private readonly recipeService = inject(RecipeService);
protected readonly loadingRecipes = signal(false); protected readonly recipes = injectQuery(() => ({
protected readonly loadRecipesError = signal<Error | null>(null); queryKey: ['recipes'],
protected readonly recipes = signal<RecipeInfoView[]>([]); queryFn: () => this.recipeService.getRecipes(),
}));
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();
}
}
} }

View File

@ -6,13 +6,13 @@
</mat-form-field> </mat-form-field>
<button matButton="filled" type="submit" [disabled]="!searchRecipesForm.valid">Search</button> <button matButton="filled" type="submit" [disabled]="!searchRecipesForm.valid">Search</button>
</form> </form>
@if (loadingResults()) { @if (givenPrompt() !== null) {
<app-spinner></app-spinner> @if (resultsQuery.isLoading()) {
} @else if (loadResultsError()) { <p>Loading search results...</p>
<p>There was an error during search. Try again.</p> } @else if (resultsQuery.isSuccess()) {
} @else if (results()?.length) { <p>Showing results for {{ givenPrompt() }}</p>
<p>Showing results for '{{ submittedPrompt() }}'</p> <app-recipe-card-grid [recipes]="resultsQuery.data()" />
<app-recipe-card-grid [recipes]="results()!" /> } @else if (resultsQuery.isError()) {
} @else if (results()?.length === 0) { <p>There was an error during search.</p>
<p>There were no results for '{{ submittedPrompt() }}'</p> }
} }

View File

@ -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 { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { RecipeService } from '../../shared/services/RecipeService'; import { RecipeService } from '../../shared/services/RecipeService';
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid'; import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { RecipeInfoView } from '../../shared/models/Recipe.model';
import { Spinner } from '../../shared/components/spinner/spinner';
@Component({ @Component({
selector: 'app-recipes-search-page', 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', templateUrl: './recipes-search-page.html',
styleUrl: './recipes-search-page.css', styleUrl: './recipes-search-page.css',
}) })
export class RecipesSearchPage implements OnInit { export class RecipesSearchPage {
private readonly recipeService = inject(RecipeService); private readonly recipeService = inject(RecipeService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly activatedRoute = inject(ActivatedRoute); private readonly activatedRoute = inject(ActivatedRoute);
public ngOnInit(): void { public constructor() {
this.activatedRoute.queryParams.subscribe((queryParams) => { this.activatedRoute.queryParams.subscribe((queryParams) => {
if (queryParams['prompt']) { if (queryParams['prompt']) {
const prompt = queryParams['prompt'] as string; this.givenPrompt.set(queryParams['prompt']);
this.searchRecipesForm.controls.prompt.setValue(prompt); this.searchRecipesForm.controls.prompt.setValue(queryParams['prompt']);
this.loadResults(prompt);
} }
}); });
} }
@ -33,34 +31,20 @@ export class RecipesSearchPage implements OnInit {
prompt: new FormControl('', [Validators.required]), prompt: new FormControl('', [Validators.required]),
}); });
protected readonly submittedPrompt = signal<string | null>(null); protected readonly givenPrompt = signal<null | string>(null);
protected readonly loadingResults = signal(false);
protected readonly loadResultsError = signal<Error | null>(null);
protected readonly results = signal<RecipeInfoView[] | null>(null);
private loadResults(prompt: string): void { protected readonly resultsQuery = injectQuery(() => ({
this.submittedPrompt.set(prompt); queryFn: () => this.recipeService.aiSearch(this.givenPrompt()!),
this.loadingResults.set(true); queryKey: ['recipes-search', this.givenPrompt()],
this.recipeService.aiSearch(prompt).subscribe({ enabled: () => !!this.givenPrompt(),
next: (results) => { }));
this.loadingResults.set(false);
this.results.set(results);
},
error: (e) => {
this.loadingResults.set(false);
this.loadResultsError.set(e);
console.error(e);
},
});
}
protected async onPromptSubmit() { protected async onPromptSubmit() {
if (this.searchRecipesForm.value.prompt) { if (this.searchRecipesForm.value.prompt) {
const prompt = this.searchRecipesForm.value.prompt;
await this.router.navigate(['/recipes-search'], { await this.router.navigate(['/recipes-search'], {
queryParams: { prompt }, queryParams: { prompt: this.searchRecipesForm.value.prompt },
}); });
this.loadResults(prompt); this.givenPrompt.set(this.searchRecipesForm.value.prompt);
} }
} }
} }

View File

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

View File

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

View File

@ -1,5 +0,0 @@
import { ImageView } from '../models/ImageView.model';
export interface ImageViewWithBlobUrl extends ImageView {
blobUrl: string;
}

View File

@ -1,6 +0,0 @@
import { IngredientDraft } from '../models/RecipeDraftView.model';
export interface IngredientDraftClientModel {
id: number;
draft: IngredientDraft;
}

View File

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

View File

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

View File

@ -0,0 +1,19 @@
import { RecipeUploadIngredientModel } from './RecipeUploadIngredientModel';
import { RecipeUploadStep } from './RecipeUploadStep';
export interface RecipeUploadModel {
inProgressStep: RecipeUploadStep;
id?: number | null;
sourceFile?: File | null;
inferredText?: string | null;
inferredIngredients?: RecipeUploadIngredientModel[] | null;
inferredTitle?: string | null;
inferredSlug?: string | null;
userText?: string | null;
userIngredients?: RecipeUploadIngredientModel[] | null;
userTitle?: string | null;
userSlug?: string | null;
}

View File

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

View File

@ -1,4 +0,0 @@
export interface ConfirmationDialogData {
title: string;
message: string;
}

View File

@ -1,8 +0,0 @@
.confirmation-actions {
display: flex;
column-gap: 10px;
}
button {
width: 100%;
}

View File

@ -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>

View File

@ -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();
});
});

View File

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

View File

@ -1,6 +0,0 @@
.dialog-container {
padding: 20px;
display: flex;
flex-direction: column;
row-gap: 10px;
}

View File

@ -1,4 +0,0 @@
<div class="dialog-container">
<h3>{{ title() }}</h3>
<ng-content></ng-content>
</div>

View File

@ -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();
});
});

View File

@ -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>();
}

View File

@ -12,8 +12,3 @@
fa-icon { fa-icon {
cursor: pointer; cursor: pointer;
} }
button {
border: none;
background: none;
}

View File

@ -1,14 +1,6 @@
<input <input #fileInput type="file" (change)="onFileChange($event)" style="display: none" />
#fileInput
type="file"
(change)="onFileChange($event)"
style="display: none"
[multiple]="mode() === 'multiple'"
/>
<div class="file-input-container"> <div class="file-input-container">
<button type="button"> <fa-icon [icon]="faFileUpload" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
<fa-icon [icon]="iconDefinition()" size="3x" (click)="onFileUploadIconClick(fileInput)"></fa-icon>
</button>
@if (fileNames().length) { @if (fileNames().length) {
@for (fileName of fileNames(); track $index) { @for (fileName of fileNames(); track $index) {
<p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p> <p class="file-name"><fa-icon [icon]="faCancel" (click)="onClear(fileName)"></fa-icon>{{ fileName }}</p>

View File

@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core'; 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 { faCancel, faFileUpload } from '@fortawesome/free-solid-svg-icons';
import { FileUploadEvent } from './FileUploadEvent'; import { FileUploadEvent } from './FileUploadEvent';
@ -11,8 +11,6 @@ import { FileUploadEvent } from './FileUploadEvent';
}) })
export class FileUpload { export class FileUpload {
public readonly files = input<File[]>([]); public readonly files = input<File[]>([]);
public readonly mode = input<'single' | 'multiple'>('single');
public readonly iconDefinition = input<IconDefinition>(faFileUpload);
public readonly fileChange = output<FileUploadEvent>(); public readonly fileChange = output<FileUploadEvent>();
protected fileNames = computed(() => this.files().map((file) => file.name)); protected fileNames = computed(() => this.files().map((file) => file.name));
@ -39,5 +37,6 @@ export class FileUpload {
fileInput.value = ''; fileInput.value = '';
} }
protected readonly faFileUpload = faFileUpload;
protected readonly faCancel = faCancel; protected readonly faCancel = faCancel;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { RecipeCard } from './recipe-card/recipe-card'; import { RecipeCard } from './recipe-card/recipe-card';
import { RecipeInfoView } from '../../models/Recipe.model'; import { Recipe } from '../../models/Recipe.model';
@Component({ @Component({
selector: 'app-recipe-card-grid', selector: 'app-recipe-card-grid',
@ -9,5 +9,5 @@ import { RecipeInfoView } from '../../models/Recipe.model';
styleUrl: './recipe-card-grid.css', styleUrl: './recipe-card-grid.css',
}) })
export class RecipeCardGrid { export class RecipeCardGrid {
public readonly recipes = input.required<RecipeInfoView[]>(); public readonly recipes = input.required<Recipe[]>();
} }

View File

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

View File

@ -1,16 +1,8 @@
@let recipe = this.recipe(); @let recipe = this.recipe();
<article> <article>
<a [routerLink]="recipePageLink()"> <a [routerLink]="recipePageLink()">
@if (loadingMainImage()) { @if (mainImage.isSuccess()) {
<app-spinner></app-spinner> <img [src]="mainImage.data()" id="recipe-card-image" [alt]="recipe.mainImage.alt" />
} @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>
} }
</a> </a>
<div id="title-and-visibility"> <div id="title-and-visibility">

View File

@ -1,20 +1,19 @@
import { Component, computed, inject, input, OnInit, signal } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { RecipeInfoView } from '../../../models/Recipe.model'; import { Recipe } from '../../../models/Recipe.model';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../services/ImageService'; import { ImageService } from '../../../services/ImageService';
import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Logo } from '../../logo/logo';
import { Spinner } from '../../spinner/spinner';
@Component({ @Component({
selector: 'app-recipe-card', selector: 'app-recipe-card',
imports: [RouterLink, FaIconComponent, Logo, Spinner], imports: [RouterLink, FaIconComponent],
templateUrl: './recipe-card.html', templateUrl: './recipe-card.html',
styleUrl: './recipe-card.css', styleUrl: './recipe-card.css',
}) })
export class RecipeCard implements OnInit { export class RecipeCard {
public recipe = input.required<RecipeInfoView>(); public recipe = input.required<Recipe>();
protected readonly recipePageLink = computed(() => { protected readonly recipePageLink = computed(() => {
const recipe = this.recipe(); const recipe = this.recipe();
@ -28,25 +27,11 @@ export class RecipeCard implements OnInit {
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
protected readonly loadingMainImage = signal(false); protected readonly mainImage = injectQuery(() => {
protected readonly loadMainImageError = signal<Error | null>(null);
protected readonly mainImage = signal<string | null>(null);
public ngOnInit(): void {
const recipe = this.recipe(); const recipe = this.recipe();
if (recipe.mainImage) { return {
this.loadingMainImage.set(true); queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
this.imageService.getImageBlobUrl(recipe.mainImage.owner.username, recipe.mainImage.filename).subscribe({ queryFn: () => this.imageService.getImage(recipe.mainImage.url),
next: (blobUrl) => { };
this.loadingMainImage.set(false);
this.mainImage.set(blobUrl);
},
error: (e) => {
this.loadingMainImage.set(false);
this.loadMainImageError.set(e);
console.error(e);
},
}); });
} }
}
}

View File

@ -11,40 +11,41 @@
} @else { } @else {
<p>You must be logged in to comment.</p> <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"> <ul id="comments">
@if (submittingComment()) { @if (addCommentMutation.isPending()) {
<li class="comment" style="opacity: 0.5"> <li class="comment" style="opacity: 0.5">
<div class="comment-username-time"> <div class="comment-username-time">
<span class="comment-username">{{ username() }}</span> <span class="comment-username">{{ username() }}</span>
</div> </div>
<div>{{ submittedComment() }}</div> <div>{{ addCommentMutation.variables() }}</div>
<app-spinner size="12px"></app-spinner>
</li> </li>
} }
@for (commentsSlice of commentsSlices(); track $index) { @for (recipeComments of commentsQuery.data()?.pages; track $index) {
@for (comment of commentsSlice.content; track $index) { @for (recipeComment of recipeComments.content; track recipeComment.id) {
<li class="comment"> <li class="comment">
<div class="comment-username-time"> <div class="comment-username-time">
<span class="comment-username">{{ comment.owner.username }}</span> <span class="comment-username">{{ recipeComment.owner.username }}</span>
<span class="comment-time">{{ comment.created | dateTimeFormat }}</span> <span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span>
</div> </div>
<div class="comment-text" [innerHTML]="comment.text"></div> <div class="comment-text" [innerHTML]="recipeComment.text"></div>
</li> </li>
} }
} }
@if (loadingComments()) {
<app-spinner></app-spinner>
}
@if (loadCommentsError()) {
<p>There was an error loading comments.</p>
}
</ul> </ul>
<div> <div>
@if (hasNextSlice()) { @if (commentsQuery.hasNextPage() && !commentsQuery.isFetchingNextPage()) {
<button (click)="loadMoreComments()">Load more comments</button> <button (click)="commentsQuery.fetchNextPage()">Load more comments</button>
} @else { } @else if (commentsQuery.isFetchingNextPage()) {
<p>No more comments.</p> <p>Loading comments...</p>
} @else if (!commentsQuery.hasNextPage()) {
<p>No additional comments to load.</p>
} }
</div> </div>
}
</div> </div>

View File

@ -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 { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { RecipeService } from '../../services/RecipeService'; import { RecipeService } from '../../services/RecipeService';
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
import { RecipeComments } from '../../models/RecipeComment.model';
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe'; 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({ @Component({
selector: 'app-recipe-comments-list', selector: 'app-recipe-comments-list',
imports: [ReactiveFormsModule, DateTimeFormatPipe, Spinner], imports: [ReactiveFormsModule, DateTimeFormatPipe],
templateUrl: './recipe-comments-list.html', templateUrl: './recipe-comments-list.html',
styleUrl: './recipe-comments-list.css', styleUrl: './recipe-comments-list.css',
}) })
export class RecipeCommentsList implements OnInit { export class RecipeCommentsList {
public readonly recipeUsername = input.required<string>(); public readonly recipeUsername = input.required<string>();
public readonly recipeSlug = 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 username = this.authService.username;
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected readonly totalComments = signal<number>(0); protected commentsQuery = injectInfiniteQuery(() => ({
initialPageParam: 0,
protected readonly loadingComments = signal(false); getNextPageParam: (previousPage: RecipeComments) =>
protected readonly loadCommentsError = signal<Error | null>(null); previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
protected readonly commentsSlices = signal<SliceView<RecipeComment>[]>([]); queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
protected readonly hasNextSlice = computed(() => queryFn: ({ pageParam }) => {
this.commentsSlices().length ? this.commentsSlices()[this.commentsSlices().length - 1].slice.hasNext : null, return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), {
); page: pageParam,
protected readonly loadedCommentsCount = computed(() => size: 10,
this.commentsSlices().reduce((acc, slice) => acc + slice.content.length, 0), sort: [
); {
property: 'created',
order: 'DESC',
},
],
});
},
}));
protected readonly addCommentForm = new FormGroup({ protected readonly addCommentForm = new FormGroup({
comment: new FormControl('', Validators.required), comment: new FormControl('', Validators.required),
}); });
protected readonly submittingComment = signal(false); protected readonly addCommentMutation = injectMutation(() => ({
protected readonly submitCommentError = signal<Error | null>(null); mutationFn: (commentText: string) =>
protected readonly submittedComment = signal<string | null>(''); this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText),
}));
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 onCommentSubmit() { protected onCommentSubmit() {
const comment = this.addCommentForm.value.comment!; this.addCommentMutation.mutate(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);
},
});
} }
} }

View File

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

View File

@ -1,5 +0,0 @@
form {
display: flex;
flex-direction: column;
row-gap: 10px;
}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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);
},
});
}
}

View File

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

View File

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

View File

@ -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();
});
});

View File

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

View File

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

View File

@ -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>

View File

@ -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();
});
});

View File

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

View File

@ -1,5 +0,0 @@
form {
display: flex;
flex-direction: column;
row-gap: 10px;
}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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,
},
});
}
}

View File

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

View File

@ -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>

View File

@ -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');
});
});

View File

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

View File

@ -1,3 +1,3 @@
@if (enabled()) { @if (enabled()) {
<span class="loader" [style]="{ width: size(), height: size() }"></span> <span class="loader"></span>
} }

View File

@ -8,5 +8,4 @@ import { Component, input } from '@angular/core';
}) })
export class Spinner { export class Spinner {
public readonly enabled = input(true); public readonly enabled = input(true);
public readonly size = input('48px');
} }

View File

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

View File

@ -1,13 +0,0 @@
import { CanActivateFn, RedirectCommand, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '../services/AuthService';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
if (authService.accessToken() === null) {
const router = inject(Router);
return new RedirectCommand(router.parseUrl('/'));
} else {
return true;
}
};

View File

@ -1,15 +1,10 @@
import { UserInfoView } from './UserInfoView.model'; import { ResourceOwner } from './ResourceOwner.model';
export interface ImageView { export interface ImageView {
url: string; alt: string;
created: Date;
modified?: Date | null;
filename: string; filename: string;
mimeType: string;
alt?: string | null;
caption?: string | null;
owner: UserInfoView;
isPublic?: boolean;
height: number | null; height: number | null;
owner: ResourceOwner;
url: string;
width: number | null; width: number | null;
} }

View File

@ -1,12 +1,11 @@
export interface QueryParams<T extends readonly string[] = any> { export interface QueryParams {
page?: number; page?: number;
size?: number; size?: number;
sort?: Array<string | Sort<T>>; sort?: Array<string | Sort>;
custom?: Record<string, any>;
} }
export interface Sort<T extends readonly string[] = any> { export interface Sort {
property: T[number]; property: string;
order?: 'ASC' | 'DESC'; order?: 'ASC' | 'DESC';
ignoreCase?: boolean; ignoreCase?: boolean;
} }

View File

@ -1,48 +1,27 @@
import { UserInfoView } from './UserInfoView.model'; import { ResourceOwner } from './ResourceOwner.model';
import { ImageView } from './ImageView.model'; import { ImageView } from './ImageView.model';
export interface RecipeInfoView { export interface RecipeInfoViews {
id: number; slice: {
created: Date; number: number;
modified?: Date | null; size: number;
slug: string; };
title: string; content: Recipe[];
preparationTime?: number | null;
cookingTime?: number | null;
totalTime?: number | null;
owner: UserInfoView;
isPublic: boolean;
starCount: number;
mainImage?: ImageView | null;
} }
export interface FullRecipeViewWrapper { export interface RecipeView {
isOwner: boolean | null; isOwner: boolean | null;
isStarred: boolean | null; isStarred: boolean | null;
recipe: FullRecipeView; recipe: Recipe;
} }
export interface FullRecipeView { export interface Recipe {
id: number; 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; isPublic: boolean;
} mainImage: ImageView;
owner: ResourceOwner;
export interface Ingredient { slug: string;
amount?: string | null; starCount: number;
name: string; text: string;
notes?: string | null; title: string;
} }

View File

@ -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 { export interface RecipeComment {
id: number; id: number;
@ -6,6 +12,6 @@ export interface RecipeComment {
modified: string | null; modified: string | null;
text: string; text: string;
rawText: string | null; rawText: string | null;
owner: UserInfoView; owner: ResourceOwner;
recipeId: number; recipeId: number;
} }

View File

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

View File

@ -1,4 +1,4 @@
export interface UserInfoView { export interface ResourceOwner {
id: number; id: number;
username: string; username: string;
} }

View File

@ -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