Compare commits
No commits in common. "main" and "with-ng-mat" have entirely different histories.
main
...
with-ng-ma
10
angular.json
10
angular.json
@ -25,7 +25,7 @@
|
|||||||
"input": "public"
|
"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
57
package-lock.json
generated
@ -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": {
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
<h1>Edit Recipe</h1>
|
|
||||||
@if (loadingRecipe()) {
|
|
||||||
<app-spinner></app-spinner>
|
|
||||||
} @else if (loadRecipeError()) {
|
|
||||||
<p>There was an error loading the recipe: {{ loadRecipeError() }}</p>
|
|
||||||
} @else {
|
|
||||||
@let recipe = recipeView()!.recipe;
|
|
||||||
<div class="recipe-info-container">
|
|
||||||
<p>Created: {{ recipe.created | date: "short" }}</p>
|
|
||||||
@if (recipe.modified) {
|
|
||||||
<p>Last modified: {{ recipe.modified | date: "short" }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<app-recipe-edit-form
|
|
||||||
[recipe]="recipe"
|
|
||||||
[editSlugDisabled]="true"
|
|
||||||
(submitRecipe)="onRecipeSubmit($event)"
|
|
||||||
></app-recipe-edit-form>
|
|
||||||
@if (submittingRecipe()) {
|
|
||||||
<app-spinner></app-spinner>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { RecipeEditPage } from './recipe-edit-page';
|
|
||||||
|
|
||||||
describe('RecipeEditPage', () => {
|
|
||||||
let component: RecipeEditPage;
|
|
||||||
let fixture: ComponentFixture<RecipeEditPage>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [RecipeEditPage],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(RecipeEditPage);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { FullRecipeViewWrapper } from '../../shared/models/Recipe.model';
|
|
||||||
import { RecipeService } from '../../shared/services/RecipeService';
|
|
||||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
|
||||||
import { DatePipe } from '@angular/common';
|
|
||||||
import { RecipeEditForm } from '../../shared/components/recipe-edit-form/recipe-edit-form';
|
|
||||||
import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
|
||||||
import { ToastrService } from 'ngx-toastr';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-recipe-edit-page',
|
|
||||||
imports: [Spinner, DatePipe, RecipeEditForm],
|
|
||||||
templateUrl: './recipe-edit-page.html',
|
|
||||||
styleUrl: './recipe-edit-page.css',
|
|
||||||
})
|
|
||||||
export class RecipeEditPage implements OnInit {
|
|
||||||
private readonly activatedRoute = inject(ActivatedRoute);
|
|
||||||
private readonly recipeService = inject(RecipeService);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly toastrService = inject(ToastrService);
|
|
||||||
|
|
||||||
protected readonly loadingRecipe = signal(false);
|
|
||||||
protected readonly loadRecipeError = signal<Error | null>(null);
|
|
||||||
protected readonly recipeView = signal<FullRecipeViewWrapper | null>(null);
|
|
||||||
|
|
||||||
protected readonly submittingRecipe = signal(false);
|
|
||||||
|
|
||||||
public ngOnInit(): void {
|
|
||||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
|
||||||
const username = paramMap.get('username')!;
|
|
||||||
const slug = paramMap.get('slug')!;
|
|
||||||
this.loadingRecipe.set(true);
|
|
||||||
this.recipeService.getRecipeView(username, slug, true).subscribe({
|
|
||||||
next: (recipeView) => {
|
|
||||||
this.loadingRecipe.set(false);
|
|
||||||
this.recipeView.set(recipeView);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.loadingRecipe.set(false);
|
|
||||||
this.loadRecipeError.set(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onRecipeSubmit(event: RecipeEditFormSubmitEvent): void {
|
|
||||||
this.submittingRecipe.set(true);
|
|
||||||
const baseRecipe = this.recipeView()!.recipe;
|
|
||||||
this.recipeService
|
|
||||||
.updateRecipe(baseRecipe.owner.username, baseRecipe.slug, {
|
|
||||||
...event,
|
|
||||||
mainImage: event.mainImage
|
|
||||||
? {
|
|
||||||
username: event.mainImage.owner.username,
|
|
||||||
filename: event.mainImage.filename,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: async () => {
|
|
||||||
this.submittingRecipe.set(false);
|
|
||||||
await this.router.navigate(['recipes', baseRecipe.owner.username, baseRecipe.slug]);
|
|
||||||
this.toastrService.success('Recipe updated');
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.submittingRecipe.set(false);
|
|
||||||
console.error(e);
|
|
||||||
this.toastrService.error('Error submitting recipe');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -26,36 +26,3 @@ article {
|
|||||||
width: 100%;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,42 +3,20 @@
|
|||||||
<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 id="star" matButton="filled" (click)="starMutation.mutate()">
|
||||||
<button
|
<div id="star-label">
|
||||||
id="star"
|
|
||||||
[matButton]="recipeView().isStarred ? 'filled' : 'outlined'"
|
|
||||||
(click)="onToggleStar()"
|
|
||||||
>
|
|
||||||
<div id="star-label">
|
|
||||||
<fa-icon [icon]="faStar" />
|
|
||||||
@if (recipeView().isStarred) {
|
|
||||||
<span>Starred</span>
|
|
||||||
} @else {
|
|
||||||
<span>Star</span>
|
|
||||||
}
|
|
||||||
<span id="star-count">{{ recipe.starCount }}</span>
|
|
||||||
@if (togglingStar()) {
|
|
||||||
<app-spinner size="12px"></app-spinner>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<div>
|
|
||||||
<fa-icon [icon]="faStar" />
|
<fa-icon [icon]="faStar" />
|
||||||
|
<span>Star</span>
|
||||||
<span id="star-count">{{ recipe.starCount }}</span>
|
<span id="star-count">{{ recipe.starCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
</button>
|
||||||
@if (isOwner()) {
|
} @else {
|
||||||
<button class="actions-button" matButton="text" [matMenuTriggerFor]="recipeActionsMenu">
|
<div>
|
||||||
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
|
<fa-icon [icon]="faStar" />
|
||||||
</button>
|
<span id="star-count">{{ recipe.starCount }}</span>
|
||||||
<mat-menu #recipeActionsMenu="matMenu">
|
</div>
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
<div [innerHTML]="recipe.text"></div>
|
||||||
@if (hasTiming()) {
|
|
||||||
<div class="timings-container">
|
|
||||||
<h3>Timings</h3>
|
|
||||||
@if (recipe.preparationTime) {
|
|
||||||
<p>Preparation time: {{ recipe.preparationTime }} minutes</p>
|
|
||||||
}
|
|
||||||
@if (recipe.cookingTime) {
|
|
||||||
<p>Cooking time: {{ recipe.cookingTime }} minutes</p>
|
|
||||||
}
|
|
||||||
@if (recipe.totalTime) {
|
|
||||||
<p>Total time: {{ recipe.totalTime }} minutes</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (recipe.ingredients?.length) {
|
|
||||||
<h3>Ingredients</h3>
|
|
||||||
<ul class="ingredients-list">
|
|
||||||
@for (ingredient of recipe.ingredients; track $index) {
|
|
||||||
<li class="ingredient">
|
|
||||||
@if (ingredient.amount) {
|
|
||||||
<span>{{ ingredient.amount }}</span>
|
|
||||||
}
|
|
||||||
<span>{{ ingredient.name }}</span>
|
|
||||||
@if (ingredient.notes) {
|
|
||||||
<span>{{ ingredient.notes }}</span>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="content-container">
|
|
||||||
<h3>Instructions</h3>
|
|
||||||
<div [innerHTML]="recipe.text"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />
|
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
#recipe-upload-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipe-upload-form {
|
||||||
|
width: 66%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
column-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*button {*/
|
||||||
|
/* width: 100%;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*input[type="text"],*/
|
||||||
|
/*textarea {*/
|
||||||
|
/* padding: 10px;*/
|
||||||
|
/* font-size: 16px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipe-form-and-source {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 10px;
|
||||||
|
justify-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recipe-form-and-source div,
|
||||||
|
#recipe-form-and-source div img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@ -1,25 +1,65 @@
|
|||||||
<h1>Upload Recipe</h1>
|
<div id="recipe-upload-container">
|
||||||
<app-recipe-upload-trail
|
<h1>Upload Recipe</h1>
|
||||||
[displayStep]="displayStep()"
|
<app-recipe-upload-trail
|
||||||
[inProgressStep]="inProgressStep()"
|
[displayStep]="displayStep()"
|
||||||
[includeInfer]="includeInfer()"
|
[inProgressStep]="inProgressStep()"
|
||||||
(stepClick)="onStepClick($event)"
|
[includeInfer]="includeInfer()"
|
||||||
></app-recipe-upload-trail>
|
(stepClick)="onStepClick($event)"
|
||||||
|
></app-recipe-upload-trail>
|
||||||
|
|
||||||
@if (displayStep() === RecipeUploadStep.START) {
|
@if (displayStep() === RecipeUploadStep.START) {
|
||||||
<app-ai-or-manual
|
<app-ai-or-manual
|
||||||
[sourceFile]="sourceFile()"
|
[sourceFile]="sourceFile()"
|
||||||
(sourceFileChange)="onSourceFileChange($event)"
|
(sourceFileChange)="onSourceFileChange($event)"
|
||||||
(submitStep)="onAiOrManualSubmit($event)"
|
(submitStep)="onAiOrManualSubmit($event)"
|
||||||
></app-ai-or-manual>
|
></app-ai-or-manual>
|
||||||
} @else if (displayStep() === RecipeUploadStep.INFER) {
|
} @else if (displayStep() === RecipeUploadStep.INFER) {
|
||||||
<app-infer></app-infer>
|
<app-infer></app-infer>
|
||||||
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||||
<app-enter-recipe-data
|
<app-enter-recipe-data [model]="model()"></app-enter-recipe-data>
|
||||||
[draft]="model().draft!"
|
}
|
||||||
(recipeSubmit)="onEnterRecipeDataSubmit($event)"
|
|
||||||
(deleteDraft)="onDeleteDraft()"
|
<!--
|
||||||
></app-enter-recipe-data>
|
<section>
|
||||||
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
|
<h2>Auto-Complete Recipe (Optional)</h2>
|
||||||
<app-review [draft]="model().draft!" (publish)="onPublish()"></app-review>
|
<p>Choose a photo of a recipe from your files, and AI will fill out the form below for you.</p>
|
||||||
}
|
<form id="recipe-upload-form" [formGroup]="recipeUploadForm" (ngSubmit)="onFileSubmit()">
|
||||||
|
<input id="file" type="file" (change)="onFileChange($event)" />
|
||||||
|
<div class="action-buttons-container">
|
||||||
|
<button matButton="outlined" type="button" (click)="onClear()">Clear</button>
|
||||||
|
<button matButton="filled" type="submit" [disabled]="!recipeUploadForm.valid || inferenceInProgress()">
|
||||||
|
AI Auto-Complete
|
||||||
|
</button>
|
||||||
|
<app-spinner [enabled]="inferenceInProgress()"></app-spinner>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Recipe Form</h2>
|
||||||
|
<div id="recipe-form-and-source">
|
||||||
|
<form [formGroup]="recipeForm" (ngSubmit)="onRecipeSubmit()">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Recipe Title</mat-label>
|
||||||
|
<input matInput id="recipe-title" type="text" formControlName="title" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Recipe Text</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
id="recipe-text"
|
||||||
|
formControlName="recipeText"
|
||||||
|
(input)="onRecipeTextChange($event)"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<button matButton="filled" type="submit" [disabled]="!recipeForm.valid || inferenceInProgress()">Add Recipe</button>
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
@if (sourceRecipeImage()) {
|
||||||
|
<img [src]="sourceRecipeImage()" alt="Your source recipe image." />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
|||||||
@ -7,68 +7,59 @@ import { EnterRecipeData } from './steps/enter-recipe-data/enter-recipe-data';
|
|||||||
import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail';
|
import { RecipeUploadTrail } from './recipe-upload-trail/recipe-upload-trail';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { StepClickEvent } from './recipe-upload-trail/StepClickEvent';
|
import { StepClickEvent } from './recipe-upload-trail/StepClickEvent';
|
||||||
import { RecipeUploadClientModel } from '../../shared/client-models/RecipeUploadClientModel';
|
import { RecipeUploadModel } from '../../shared/client-models/RecipeUploadModel';
|
||||||
import { RecipeDraftService } from '../../shared/services/RecipeDraftService';
|
import { RecipeUploadService } from '../../shared/services/RecipeUploadService';
|
||||||
import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
|
import { RecipeUploadStep } from '../../shared/client-models/RecipeUploadStep';
|
||||||
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
|
import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadEvent';
|
||||||
import { tryMaybeInt } from '../../shared/util';
|
import { tryMaybeInt } from '../../shared/util';
|
||||||
import { from, map, switchMap, tap } from 'rxjs';
|
import { from, map, switchMap, tap } from 'rxjs';
|
||||||
import { Review } from './steps/review/review';
|
|
||||||
import { 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,
|
}));
|
||||||
},
|
await this.changeDisplayStep(RecipeUploadStep.INFER);
|
||||||
true,
|
this.includeInfer.set(true);
|
||||||
);
|
this.recipeUploadService.doInference(this.model()).subscribe((updatedModel) => {
|
||||||
this.recipeDraftService
|
this.model.set(updatedModel);
|
||||||
.startInference(this.model())
|
this.changeDisplayStep(RecipeUploadStep.ENTER_DATA);
|
||||||
.pipe(
|
});
|
||||||
tap((updated) => {
|
|
||||||
this.switchModel(updated, true);
|
|
||||||
}),
|
|
||||||
switchMap((model) => this.recipeDraftService.pollUntilInferenceComplete(model)),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (inferredModel) => {
|
|
||||||
this.switchModel(inferredModel, true);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
console.error(e);
|
|
||||||
this.toastrService.error('Error while extracting recipe data');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
'step-complete': step.completed,
|
'step-complete': step.completed,
|
||||||
'step-in-progress': step.inProgress,
|
'step-in-progress': step.inProgress,
|
||||||
'step-incomplete': !step.completed,
|
'step-incomplete': !step.completed,
|
||||||
'step-displayed': displayStep() === step.index,
|
'step-displayed': displayStep() === step.index
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@if (step.completed || step.inProgress) {
|
@if (step.completed || step.inProgress) {
|
||||||
|
|||||||
@ -34,12 +34,6 @@ export class RecipeUploadTrail {
|
|||||||
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
|
completed: this.inProgressStep() > RecipeUploadStep.ENTER_DATA,
|
||||||
inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA,
|
inProgress: this.inProgressStep() === RecipeUploadStep.ENTER_DATA,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
index: RecipeUploadStep.REVIEW,
|
|
||||||
name: 'Review',
|
|
||||||
completed: this.inProgressStep() > RecipeUploadStep.REVIEW,
|
|
||||||
inProgress: this.inProgressStep() === RecipeUploadStep.REVIEW,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
if (this.includeInfer()) {
|
if (this.includeInfer()) {
|
||||||
base.push({
|
base.push({
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
.in-progress-draft-link,
|
|
||||||
.in-progress-draft-list-item {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 5px;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-draft-link * {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-saved {
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-title {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
@ -1,40 +1,11 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Start</h2>
|
<h2>Start</h2>
|
||||||
<section>
|
<p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p>
|
||||||
@if (loadingDrafts()) {
|
<form id="ai-or-manual-form">
|
||||||
<app-spinner></app-spinner>
|
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
|
||||||
} @else if (drafts().length) {
|
<div id="ai-or-manual-buttons">
|
||||||
<h3>In Progress Drafts</h3>
|
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
|
||||||
<ul>
|
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">Use AI Assist</button>
|
||||||
@for (draft of drafts(); track $index) {
|
</div>
|
||||||
<li class="in-progress-draft-list-item">
|
</form>
|
||||||
<a class="in-progress-draft-link" (click)="onInProgressDraftClick(draft)">
|
|
||||||
<fa-icon [icon]="faFilePen"></fa-icon>
|
|
||||||
@if (draft.title) {
|
|
||||||
<span>{{ draft.title }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="no-title">(No title)</span>
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
<span class="last-saved"
|
|
||||||
>(Last saved {{ (draft.modified ? draft.modified : draft.created) | date: "short" }})</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h3>New Draft</h3>
|
|
||||||
<p>Either upload a photo of a recipe and AI will assist you, or enter your recipe manually.</p>
|
|
||||||
<form id="ai-or-manual-form">
|
|
||||||
<app-file-upload [files]="sourceFilesArray()" (fileChange)="onFileChange($event)"></app-file-upload>
|
|
||||||
<div id="ai-or-manual-buttons">
|
|
||||||
<button matButton="outlined" type="button" (click)="onFormSubmit('manual')">Enter Manually</button>
|
|
||||||
<button matButton="filled" type="button" [disabled]="!sourceFile()" (click)="onFormSubmit('ai-assist')">
|
|
||||||
Use AI Assist
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,33 +1,21 @@
|
|||||||
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import { MatButton } from '@angular/material/button';
|
import { 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
<section>
|
|
||||||
<h2>Review and Publish</h2>
|
|
||||||
<p>Title: {{ draft().title }}</p>
|
|
||||||
<p>Slug: {{ draft().slug }}</p>
|
|
||||||
<div>
|
|
||||||
<p>Text: todo</p>
|
|
||||||
</div>
|
|
||||||
<button matButton="filled" (click)="onPublish()">Publish</button>
|
|
||||||
</section>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Review } from './review';
|
|
||||||
|
|
||||||
describe('Review', () => {
|
|
||||||
let component: Review;
|
|
||||||
let fixture: ComponentFixture<Review>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Review],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Review);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
|
||||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-review',
|
|
||||||
imports: [MatButton],
|
|
||||||
templateUrl: './review.html',
|
|
||||||
styleUrl: './review.css',
|
|
||||||
})
|
|
||||||
export class Review {
|
|
||||||
public readonly draft = input.required<RecipeDraftViewModel>();
|
|
||||||
public readonly publish = output<void>();
|
|
||||||
|
|
||||||
protected onPublish(): void {
|
|
||||||
this.publish.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +1,8 @@
|
|||||||
<h1>Recipes</h1>
|
<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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
export interface ImageUpdateBody {
|
|
||||||
alt?: string | null;
|
|
||||||
caption?: string | null;
|
|
||||||
isPublic?: boolean | null;
|
|
||||||
viewersToAdd?: string[] | null;
|
|
||||||
viewersToRemove?: string[] | null;
|
|
||||||
clearAllViewers?: boolean | null;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
export interface RecipeUpdateBody {
|
|
||||||
title?: string | null;
|
|
||||||
preparationTime?: number | null;
|
|
||||||
cookingTime?: number | null;
|
|
||||||
totalTime?: number | null;
|
|
||||||
ingredients?: IngredientUpdateBody[] | null;
|
|
||||||
rawText?: string | null;
|
|
||||||
isPublic?: boolean | null;
|
|
||||||
mainImage?: MainImageUpdateBody | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IngredientUpdateBody {
|
|
||||||
amount?: string | null;
|
|
||||||
name: string;
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MainImageUpdateBody {
|
|
||||||
username: string;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { ImageView } from '../models/ImageView.model';
|
|
||||||
|
|
||||||
export interface ImageViewWithBlobUrl extends ImageView {
|
|
||||||
blobUrl: string;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { IngredientDraft } from '../models/RecipeDraftView.model';
|
|
||||||
|
|
||||||
export interface IngredientDraftClientModel {
|
|
||||||
id: number;
|
|
||||||
draft: IngredientDraft;
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { RecipeUploadStep } from './RecipeUploadStep';
|
|
||||||
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
|
|
||||||
|
|
||||||
export interface RecipeUploadClientModel {
|
|
||||||
inProgressStep: RecipeUploadStep;
|
|
||||||
inputSourceFile?: File | null;
|
|
||||||
draft?: RecipeDraftViewModel;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export interface RecipeUploadIngredientModel {
|
||||||
|
amount: string | null;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
19
src/app/shared/client-models/RecipeUploadModel.ts
Normal file
19
src/app/shared/client-models/RecipeUploadModel.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { RecipeUploadIngredientModel } from './RecipeUploadIngredientModel';
|
||||||
|
import { RecipeUploadStep } from './RecipeUploadStep';
|
||||||
|
|
||||||
|
export interface RecipeUploadModel {
|
||||||
|
inProgressStep: RecipeUploadStep;
|
||||||
|
|
||||||
|
id?: number | null;
|
||||||
|
sourceFile?: File | null;
|
||||||
|
|
||||||
|
inferredText?: string | null;
|
||||||
|
inferredIngredients?: RecipeUploadIngredientModel[] | null;
|
||||||
|
inferredTitle?: string | null;
|
||||||
|
inferredSlug?: string | null;
|
||||||
|
|
||||||
|
userText?: string | null;
|
||||||
|
userIngredients?: RecipeUploadIngredientModel[] | null;
|
||||||
|
userTitle?: string | null;
|
||||||
|
userSlug?: string | null;
|
||||||
|
}
|
||||||
@ -2,5 +2,4 @@ export enum RecipeUploadStep {
|
|||||||
START,
|
START,
|
||||||
INFER,
|
INFER,
|
||||||
ENTER_DATA,
|
ENTER_DATA,
|
||||||
REVIEW,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
export interface ConfirmationDialogData {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
.confirmation-actions {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<app-dialog-container [title]="data.title">
|
|
||||||
<p>{{ data.message }}</p>
|
|
||||||
<div class="confirmation-actions">
|
|
||||||
<button matButton="outlined" (click)="onCancel()">Cancel</button>
|
|
||||||
<button matButton="filled" (click)="onOk()">Ok</button>
|
|
||||||
</div>
|
|
||||||
</app-dialog-container>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { ConfirmationDialog } from './confirmation-dialog';
|
|
||||||
|
|
||||||
describe('ConfirmationDialog', () => {
|
|
||||||
let component: ConfirmationDialog;
|
|
||||||
let fixture: ComponentFixture<ConfirmationDialog>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [ConfirmationDialog],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ConfirmationDialog);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { DialogContainer } from '../dialog-container/dialog-container';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { ConfirmationDialogData } from './ConfirmationDialogData';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-confirmation-dialog',
|
|
||||||
imports: [DialogContainer, MatButton],
|
|
||||||
templateUrl: './confirmation-dialog.html',
|
|
||||||
styleUrl: './confirmation-dialog.css',
|
|
||||||
})
|
|
||||||
export class ConfirmationDialog {
|
|
||||||
protected readonly data: ConfirmationDialogData = inject(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
private readonly dialogRef: MatDialogRef<ConfirmationDialogData, boolean> = inject(MatDialogRef);
|
|
||||||
|
|
||||||
protected onCancel(): void {
|
|
||||||
this.dialogRef.close(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onOk(): void {
|
|
||||||
this.dialogRef.close(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
.dialog-container {
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<div class="dialog-container">
|
|
||||||
<h3>{{ title() }}</h3>
|
|
||||||
<ng-content></ng-content>
|
|
||||||
</div>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DialogContainer } from './dialog-container';
|
|
||||||
|
|
||||||
describe('DialogContainer', () => {
|
|
||||||
let component: DialogContainer;
|
|
||||||
let fixture: ComponentFixture<DialogContainer>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [DialogContainer],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DialogContainer);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Component, input } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-dialog-container',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './dialog-container.html',
|
|
||||||
styleUrl: './dialog-container.css',
|
|
||||||
})
|
|
||||||
export class DialogContainer {
|
|
||||||
public readonly title = input.required<string>();
|
|
||||||
}
|
|
||||||
@ -12,8 +12,3 @@
|
|||||||
fa-icon {
|
fa-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
div {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: var(--primary-red);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.utensils {
|
|
||||||
color: var(--primary-white);
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<div>
|
|
||||||
<fa-icon [icon]="faUtensils" size="2x" class="utensils"></fa-icon>
|
|
||||||
</div>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Logo } from './logo';
|
|
||||||
|
|
||||||
describe('Logo', () => {
|
|
||||||
let component: Logo;
|
|
||||||
let fixture: ComponentFixture<Logo>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Logo],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Logo);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { faUtensils } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-logo',
|
|
||||||
imports: [FaIconComponent],
|
|
||||||
templateUrl: './logo.html',
|
|
||||||
styleUrl: './logo.css',
|
|
||||||
})
|
|
||||||
export class Logo {
|
|
||||||
protected readonly faUtensils = faUtensils;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface NavLinkConfig {
|
|
||||||
relativeUrl: string;
|
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,12 +1,8 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<h2>Nav</h2>
|
<h2>Nav</h2>
|
||||||
<ul>
|
<ul>
|
||||||
@for (link of links(); track link.relativeUrl) {
|
<li><a [routerLink]="'/'">Browse Recipes</a></li>
|
||||||
@if (!link.disabled) {
|
<li><a [routerLink]="'/recipes-search'">Search Recipes</a></li>
|
||||||
<li>
|
<li><a [routerLink]="'/recipe-upload'">Upload Recipe</a></li>
|
||||||
<a [routerLink]="link.relativeUrl">{{ link.title }}</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { AuthService } from '../../services/AuthService';
|
|
||||||
import { NavLinkConfig } from './NavLinkConfig';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-nav',
|
selector: 'app-nav',
|
||||||
@ -9,24 +7,4 @@ import { NavLinkConfig } from './NavLinkConfig';
|
|||||||
templateUrl: './nav.html',
|
templateUrl: './nav.html',
|
||||||
styleUrl: './nav.css',
|
styleUrl: './nav.css',
|
||||||
})
|
})
|
||||||
export class Nav {
|
export class Nav {}
|
||||||
private readonly authService = inject(AuthService);
|
|
||||||
|
|
||||||
protected readonly links = computed<NavLinkConfig[]>(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
relativeUrl: '/',
|
|
||||||
title: 'Browse Recipes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relativeUrl: '/recipes-search',
|
|
||||||
title: 'Search Recipes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relativeUrl: '/recipe-upload',
|
|
||||||
title: 'Upload Recipe',
|
|
||||||
disabled: this.authService.accessToken() === null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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[]>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
<ul id="comments">
|
@if (commentsQuery.isPending()) {
|
||||||
@if (submittingComment()) {
|
<p>Loading comments...</p>
|
||||||
<li class="comment" style="opacity: 0.5">
|
} @else if (commentsQuery.isError()) {
|
||||||
<div class="comment-username-time">
|
<p>There was an error loading the comments.</p>
|
||||||
<span class="comment-username">{{ username() }}</span>
|
} @else {
|
||||||
</div>
|
<ul id="comments">
|
||||||
<div>{{ submittedComment() }}</div>
|
@if (addCommentMutation.isPending()) {
|
||||||
<app-spinner size="12px"></app-spinner>
|
<li class="comment" style="opacity: 0.5">
|
||||||
</li>
|
|
||||||
}
|
|
||||||
@for (commentsSlice of commentsSlices(); track $index) {
|
|
||||||
@for (comment of commentsSlice.content; track $index) {
|
|
||||||
<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">{{ username() }}</span>
|
||||||
<span class="comment-time">{{ comment.created | dateTimeFormat }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text" [innerHTML]="comment.text"></div>
|
<div>{{ addCommentMutation.variables() }}</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
@for (recipeComments of commentsQuery.data()?.pages; track $index) {
|
||||||
@if (loadingComments()) {
|
@for (recipeComment of recipeComments.content; track recipeComment.id) {
|
||||||
<app-spinner></app-spinner>
|
<li class="comment">
|
||||||
}
|
<div class="comment-username-time">
|
||||||
@if (loadCommentsError()) {
|
<span class="comment-username">{{ recipeComment.owner.username }}</span>
|
||||||
<p>There was an error loading comments.</p>
|
<span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span>
|
||||||
}
|
</div>
|
||||||
</ul>
|
<div class="comment-text" [innerHTML]="recipeComment.text"></div>
|
||||||
<div>
|
</li>
|
||||||
@if (hasNextSlice()) {
|
}
|
||||||
<button (click)="loadMoreComments()">Load more comments</button>
|
}
|
||||||
} @else {
|
</ul>
|
||||||
<p>No more comments.</p>
|
<div>
|
||||||
}
|
@if (commentsQuery.hasNextPage() && !commentsQuery.isFetchingNextPage()) {
|
||||||
</div>
|
<button (click)="commentsQuery.fetchNextPage()">Load more comments</button>
|
||||||
|
} @else if (commentsQuery.isFetchingNextPage()) {
|
||||||
|
<p>Loading comments...</p>
|
||||||
|
} @else if (!commentsQuery.hasNextPage()) {
|
||||||
|
<p>No additional comments to load.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,20 +1,18 @@
|
|||||||
import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { 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,32 +22,14 @@ 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(() =>
|
|
||||||
this.commentsSlices().reduce((acc, slice) => acc + slice.content.length, 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected readonly addCommentForm = new FormGroup({
|
|
||||||
comment: new FormControl('', Validators.required),
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly submittingComment = signal(false);
|
|
||||||
protected readonly submitCommentError = signal<Error | null>(null);
|
|
||||||
protected readonly submittedComment = signal<string | null>('');
|
|
||||||
|
|
||||||
public ngOnInit(): void {
|
|
||||||
this.loadingComments.set(true);
|
|
||||||
this.loadCommentCount();
|
|
||||||
this.recipeService
|
|
||||||
.getComments(this.recipeUsername(), this.recipeSlug(), {
|
|
||||||
page: 0,
|
|
||||||
size: 10,
|
size: 10,
|
||||||
sort: [
|
sort: [
|
||||||
{
|
{
|
||||||
@ -57,100 +37,20 @@ export class RecipeCommentsList implements OnInit {
|
|||||||
order: 'DESC',
|
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 {
|
protected readonly addCommentForm = new FormGroup({
|
||||||
this.recipeService.getCommentsCount(this.recipeUsername(), this.recipeSlug()).subscribe({
|
comment: new FormControl('', Validators.required),
|
||||||
next: (count) => {
|
});
|
||||||
this.totalComments.set(count);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
console.error(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected loadMoreComments(): void {
|
protected readonly addCommentMutation = injectMutation(() => ({
|
||||||
if (this.hasNextSlice()) {
|
mutationFn: (commentText: string) =>
|
||||||
this.loadingComments.set(true);
|
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText),
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import { IngredientDraft } from '../../models/RecipeDraftView.model';
|
|
||||||
import { ImageView } from '../../models/ImageView.model';
|
|
||||||
|
|
||||||
export interface RecipeEditFormSubmitEvent {
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
ingredients: IngredientDraft[];
|
|
||||||
preparationTime: number | null;
|
|
||||||
cookingTime: number | null;
|
|
||||||
totalTime: number | null;
|
|
||||||
mainImage: ImageView | null;
|
|
||||||
rawText: string;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<app-dialog-container title="Edit Image">
|
|
||||||
@if (loading()) {
|
|
||||||
<app-spinner></app-spinner>
|
|
||||||
} @else if (loadError()) {
|
|
||||||
<p>There was an error.</p>
|
|
||||||
} @else {
|
|
||||||
<form (submit)="onSubmit($event)">
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Filename</mat-label>
|
|
||||||
<input matInput [formControl]="imageForm.controls.filename" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Alt</mat-label>
|
|
||||||
<input matInput [formControl]="imageForm.controls.alt" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Caption</mat-label>
|
|
||||||
<input matInput [formControl]="imageForm.controls.caption" />
|
|
||||||
</mat-form-field>
|
|
||||||
<button type="submit" matButton="filled">Submit</button>
|
|
||||||
@if (submitting()) {
|
|
||||||
<app-spinner size="24px"></app-spinner>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</app-dialog-container>
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
import { By } from '@angular/platform-browser';
|
|
||||||
import { EditImageDialog } from './edit-image-dialog';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { Mocked } from 'vitest';
|
|
||||||
import { ImageService } from '../../../../services/ImageService';
|
|
||||||
import { ImageView } from '../../../../models/ImageView.model';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
|
|
||||||
describe('EditImageDialog', () => {
|
|
||||||
let component: EditImageDialog;
|
|
||||||
let fixture: ComponentFixture<EditImageDialog>;
|
|
||||||
let matDialogRef: Partial<Mocked<MatDialogRef<any>>>;
|
|
||||||
|
|
||||||
const username = 'test-user';
|
|
||||||
const filename = 'test-file.jpg';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
matDialogRef = {
|
|
||||||
close: vi.fn(),
|
|
||||||
} as Partial<Mocked<MatDialogRef<any>>>;
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [EditImageDialog],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: MAT_DIALOG_DATA,
|
|
||||||
useValue: { username, filename },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: MatDialogRef,
|
|
||||||
useValue: matDialogRef,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ImageService,
|
|
||||||
useValue: {
|
|
||||||
getImageView2: vi.fn((username, filename) =>
|
|
||||||
of({
|
|
||||||
filename,
|
|
||||||
owner: {
|
|
||||||
username,
|
|
||||||
},
|
|
||||||
} as ImageView),
|
|
||||||
),
|
|
||||||
updateImage: vi.fn(() => of({} as ImageView)),
|
|
||||||
} as Partial<Mocked<ImageService>>,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(EditImageDialog);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close dialog after successful submit', async () => {
|
|
||||||
const formDebug = fixture.debugElement.query(By.css('form'));
|
|
||||||
expect(formDebug).toBeTruthy();
|
|
||||||
const formElement = formDebug.nativeElement as HTMLFormElement;
|
|
||||||
formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
||||||
expect(matDialogRef.close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
|
||||||
import { DialogContainer } from '../../../dialog-container/dialog-container';
|
|
||||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { ImageView } from '../../../../models/ImageView.model';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
import { ImageService } from '../../../../services/ImageService';
|
|
||||||
import { notNullOrUndefined } from '../../../../util';
|
|
||||||
import { Spinner } from '../../../spinner/spinner';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-edit-image-dialog',
|
|
||||||
imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton, Spinner],
|
|
||||||
templateUrl: './edit-image-dialog.html',
|
|
||||||
styleUrl: './edit-image-dialog.css',
|
|
||||||
})
|
|
||||||
export class EditImageDialog implements OnInit {
|
|
||||||
private readonly usernameFilename: [username: string, filename: string] = inject(MAT_DIALOG_DATA);
|
|
||||||
private readonly dialogRef = inject(MatDialogRef);
|
|
||||||
private readonly imageService = inject(ImageService);
|
|
||||||
|
|
||||||
protected readonly loading = signal(false);
|
|
||||||
protected readonly loadError = signal<Error | null>(null);
|
|
||||||
protected readonly imageView = signal<ImageView | null>(null);
|
|
||||||
|
|
||||||
protected readonly submitting = signal(false);
|
|
||||||
protected readonly submitError = signal<Error | null>(null);
|
|
||||||
|
|
||||||
protected readonly imageForm = new FormGroup({
|
|
||||||
filename: new FormControl(
|
|
||||||
{
|
|
||||||
value: '',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
Validators.required,
|
|
||||||
),
|
|
||||||
alt: new FormControl(''),
|
|
||||||
caption: new FormControl(''),
|
|
||||||
});
|
|
||||||
|
|
||||||
public ngOnInit(): void {
|
|
||||||
this.loading.set(true);
|
|
||||||
this.imageService.getImageView2(this.usernameFilename[0], this.usernameFilename[1]).subscribe({
|
|
||||||
next: (imageView) => {
|
|
||||||
this.loading.set(false); // shouldn't need this
|
|
||||||
this.imageView.set(imageView);
|
|
||||||
this.imageForm.patchValue({
|
|
||||||
filename: imageView.filename,
|
|
||||||
alt: imageView.alt,
|
|
||||||
caption: imageView.caption,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.loading.set(false);
|
|
||||||
this.loadError.set(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async onSubmit(event: SubmitEvent): Promise<void> {
|
|
||||||
event.preventDefault();
|
|
||||||
const imageView = this.imageView()!;
|
|
||||||
const formValue = this.imageForm.value;
|
|
||||||
if (notNullOrUndefined(formValue.alt)) {
|
|
||||||
imageView.alt = formValue.alt;
|
|
||||||
}
|
|
||||||
if (notNullOrUndefined(formValue.caption)) {
|
|
||||||
imageView.caption = formValue.caption;
|
|
||||||
}
|
|
||||||
this.submitting.set(true);
|
|
||||||
this.imageService.updateImage(imageView.owner.username, imageView.filename, imageView).subscribe({
|
|
||||||
next: (imageView) => {
|
|
||||||
this.submitting.set(false);
|
|
||||||
this.imageView.set(imageView);
|
|
||||||
this.dialogRef.close();
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.submitting.set(false);
|
|
||||||
this.submitError.set(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
.image-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-grid-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-filename {
|
|
||||||
font-size: 0.75em;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-card-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
@if (loadingImages()) {
|
|
||||||
<app-spinner></app-spinner>
|
|
||||||
} @else if (loadImagesError()) {
|
|
||||||
<p>There was an error loading images.</p>
|
|
||||||
} @else {
|
|
||||||
<div class="image-grid">
|
|
||||||
@for (image of imagesSlice()!.content; track $index) {
|
|
||||||
<mat-card>
|
|
||||||
<img
|
|
||||||
mat-card-image
|
|
||||||
[src]="image.blobUrl"
|
|
||||||
[alt]="image.alt"
|
|
||||||
(click)="onImageClick(image)"
|
|
||||||
class="image-grid-image"
|
|
||||||
/>
|
|
||||||
<mat-card-content>
|
|
||||||
<p class="image-filename">{{ image.filename }}</p>
|
|
||||||
</mat-card-content>
|
|
||||||
<mat-card-actions>
|
|
||||||
<mat-checkbox [checked]="isSelected(image)" (click)="onImageClick(image)">Main?</mat-checkbox>
|
|
||||||
<button matButton="text" type="button" [matMenuTriggerFor]="imageActionsMenu">
|
|
||||||
<fa-icon [icon]="faEllipsis"></fa-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #imageActionsMenu>
|
|
||||||
<button mat-menu-item type="button" (click)="editImage(image)">Edit</button>
|
|
||||||
<button mat-menu-item type="button" (click)="deleteImage(image)">Delete</button>
|
|
||||||
</mat-menu>
|
|
||||||
</mat-card-actions>
|
|
||||||
</mat-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (deleting()) {
|
|
||||||
<app-spinner size="24px"></app-spinner>
|
|
||||||
} @else if (deleteError()) {
|
|
||||||
<p>There was an error deleting the image.</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-paginator
|
|
||||||
[length]="imageCount()"
|
|
||||||
[pageSize]="pageSize()"
|
|
||||||
[pageSizeOptions]="[3, 6, 9, 12]"
|
|
||||||
(page)="onPage($event)"
|
|
||||||
></mat-paginator>
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
import { ImageSelect } from './image-select';
|
|
||||||
import { Mocked } from 'vitest';
|
|
||||||
import { ImageService } from '../../../services/ImageService';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { SliceView, SliceViewMeta } from '../../../models/SliceView.model';
|
|
||||||
import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
|
|
||||||
|
|
||||||
describe('ImageSelect', () => {
|
|
||||||
let component: ImageSelect;
|
|
||||||
let fixture: ComponentFixture<ImageSelect>;
|
|
||||||
|
|
||||||
let imageService: Partial<Mocked<ImageService>> = {
|
|
||||||
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
|
|
||||||
of({
|
|
||||||
content: [],
|
|
||||||
slice: {} as SliceViewMeta,
|
|
||||||
count: 0,
|
|
||||||
} as SliceView<ImageViewWithBlobUrl>),
|
|
||||||
),
|
|
||||||
getOwnedImagesCount: vi.fn(() => of(0)),
|
|
||||||
deleteImage: vi.fn(() => of()),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [ImageSelect],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ImageService,
|
|
||||||
useValue: imageService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ImageSelect);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
|
|
||||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
|
||||||
import { ImageService } from '../../../services/ImageService';
|
|
||||||
import { Spinner } from '../../spinner/spinner';
|
|
||||||
import { ImageView } from '../../../models/ImageView.model';
|
|
||||||
import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
|
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
|
|
||||||
import { SliceView } from '../../../models/SliceView.model';
|
|
||||||
import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-image-select',
|
|
||||||
imports: [
|
|
||||||
MatPaginator,
|
|
||||||
Spinner,
|
|
||||||
MatCard,
|
|
||||||
MatCardImage,
|
|
||||||
MatCardContent,
|
|
||||||
MatCardActions,
|
|
||||||
MatCheckbox,
|
|
||||||
MatMenuTrigger,
|
|
||||||
MatMenu,
|
|
||||||
FaIconComponent,
|
|
||||||
MatButton,
|
|
||||||
MatMenuItem,
|
|
||||||
],
|
|
||||||
templateUrl: './image-select.html',
|
|
||||||
styleUrl: './image-select.css',
|
|
||||||
})
|
|
||||||
export class ImageSelect implements OnInit {
|
|
||||||
public readonly select = output<ImageView | null>();
|
|
||||||
public readonly selectedUsernameFilename = input<readonly [string, string] | null>(null);
|
|
||||||
|
|
||||||
protected readonly currentPage = signal(0);
|
|
||||||
protected readonly pageSize = signal(9);
|
|
||||||
|
|
||||||
private readonly imageService = inject(ImageService);
|
|
||||||
private readonly dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
protected readonly loadingImages = signal(false);
|
|
||||||
protected readonly loadImagesError = signal<Error | null>(null);
|
|
||||||
protected readonly imagesSlice = signal<SliceView<ImageViewWithBlobUrl> | null>(null);
|
|
||||||
|
|
||||||
protected readonly imageCount = signal(0);
|
|
||||||
|
|
||||||
protected readonly deleting = signal(false);
|
|
||||||
protected readonly deleteError = signal<Error | null>(null);
|
|
||||||
|
|
||||||
public ngOnInit(): void {
|
|
||||||
this.loadImages();
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadImages(): void {
|
|
||||||
this.loadingImages.set(true);
|
|
||||||
this.imageService
|
|
||||||
.getOwnedImageViewsWithBlobUrls({
|
|
||||||
page: this.currentPage(),
|
|
||||||
size: this.pageSize(),
|
|
||||||
sort: [
|
|
||||||
{
|
|
||||||
property: 'created',
|
|
||||||
order: 'DESC',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (sliceView) => {
|
|
||||||
sliceView.content.sort((a, b) => b.created.valueOf() - a.created.valueOf());
|
|
||||||
this.loadingImages.set(false);
|
|
||||||
this.imagesSlice.set(sliceView);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.loadingImages.set(false);
|
|
||||||
this.loadImagesError.set(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.imageService.getOwnedImagesCount().subscribe({
|
|
||||||
next: (count) => {
|
|
||||||
this.imageCount.set(count);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onPage(pageEvent: PageEvent): void {
|
|
||||||
if (pageEvent.pageIndex < this.currentPage()) {
|
|
||||||
// backward
|
|
||||||
this.currentPage.update((old) => Math.max(old - 1, 0));
|
|
||||||
} else {
|
|
||||||
// forward
|
|
||||||
this.currentPage.update((old) => (this.imagesSlice()?.slice.hasNext ? old + 1 : old));
|
|
||||||
}
|
|
||||||
this.pageSize.set(pageEvent.pageSize);
|
|
||||||
this.loadImages();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onImageClick(imageView: ImageView): void {
|
|
||||||
if (this.isSelected(imageView)) {
|
|
||||||
this.select.emit(null);
|
|
||||||
} else {
|
|
||||||
this.select.emit(imageView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isSelected(imageView: ImageView): boolean {
|
|
||||||
const selectedUsernameFilename = this.selectedUsernameFilename();
|
|
||||||
if (selectedUsernameFilename) {
|
|
||||||
const [username, filename] = selectedUsernameFilename;
|
|
||||||
return imageView.owner.username === username && imageView.filename === filename;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected editImage(imageView: ImageView): void {
|
|
||||||
this.dialog.open(EditImageDialog, {
|
|
||||||
data: [imageView.owner.username, imageView.filename],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected deleteImage(imageView: ImageView): void {
|
|
||||||
this.deleting.set(true);
|
|
||||||
this.imageService.deleteImage(imageView.owner.username, imageView.filename).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.deleting.set(false);
|
|
||||||
this.loadImages();
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.deleting.set(false);
|
|
||||||
this.deleteError.set(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly faEllipsis = faEllipsis;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
.image-upload-dialog-content {
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
row-gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button div {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<app-dialog-container title="Image Upload">
|
|
||||||
<form>
|
|
||||||
@let file = fileToUpload();
|
|
||||||
@if (file !== null) {
|
|
||||||
<app-file-upload
|
|
||||||
[files]="[file]"
|
|
||||||
(fileChange)="onImageFileEvent($event)"
|
|
||||||
[iconDefinition]="faFileImage"
|
|
||||||
></app-file-upload>
|
|
||||||
} @else {
|
|
||||||
<app-file-upload
|
|
||||||
[files]="[]"
|
|
||||||
(fileChange)="onImageFileEvent($event)"
|
|
||||||
[iconDefinition]="faFileImage"
|
|
||||||
></app-file-upload>
|
|
||||||
}
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Filename</mat-label>
|
|
||||||
<input matInput [formControl]="imageUploadForm.controls.filename" />
|
|
||||||
@if (imageUploadForm.controls.filename.hasError("imageExists")) {
|
|
||||||
<mat-error>Filename taken. Choose a different name.</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Alt</mat-label>
|
|
||||||
<input matInput [formControl]="imageUploadForm.controls.alt" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Caption</mat-label>
|
|
||||||
<input matInput [formControl]="imageUploadForm.controls.caption" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-checkbox [formControl]="imageUploadForm.controls.isPublic">Is Public?</mat-checkbox>
|
|
||||||
@if (error()) {
|
|
||||||
<p>{{ error() }}</p>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
matButton="filled"
|
|
||||||
(click)="onSubmit()"
|
|
||||||
[disabled]="submitDisabled() || imageUploadForm.invalid"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span>Upload</span>
|
|
||||||
<app-spinner [enabled]="inProgress()" size="24px"></app-spinner>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</app-dialog-container>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { ImageUploadDialog } from './image-upload-dialog';
|
|
||||||
|
|
||||||
describe('ImageUploadDialog', () => {
|
|
||||||
let component: ImageUploadDialog;
|
|
||||||
let fixture: ComponentFixture<ImageUploadDialog>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [ImageUploadDialog],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ImageUploadDialog);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
|
||||||
import { ImageService } from '../../../services/ImageService';
|
|
||||||
import { FileUpload } from '../../file-upload/file-upload';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
import { faFileImage } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FileUploadEvent } from '../../file-upload/FileUploadEvent';
|
|
||||||
import { Spinner } from '../../spinner/spinner';
|
|
||||||
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
|
||||||
import { MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { ImageDoesNotExistValidator } from '../../../validators/image-does-not-exist-validator';
|
|
||||||
import { DialogContainer } from '../../dialog-container/dialog-container';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-image-upload-dialog',
|
|
||||||
imports: [
|
|
||||||
FileUpload,
|
|
||||||
MatButton,
|
|
||||||
Spinner,
|
|
||||||
MatFormField,
|
|
||||||
MatLabel,
|
|
||||||
MatInput,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
MatCheckbox,
|
|
||||||
MatError,
|
|
||||||
DialogContainer,
|
|
||||||
],
|
|
||||||
templateUrl: './image-upload-dialog.html',
|
|
||||||
styleUrl: './image-upload-dialog.css',
|
|
||||||
})
|
|
||||||
export class ImageUploadDialog {
|
|
||||||
public readonly dialogRef = inject(MatDialogRef);
|
|
||||||
|
|
||||||
protected readonly fileToUpload = signal<File | null>(null);
|
|
||||||
protected readonly inProgress = signal(false);
|
|
||||||
protected readonly submitDisabled = computed(() => !this.fileToUpload() || this.inProgress());
|
|
||||||
protected readonly error = signal<string | null>(null);
|
|
||||||
|
|
||||||
private readonly imageDoesNotExistValidator = inject(ImageDoesNotExistValidator);
|
|
||||||
|
|
||||||
protected readonly imageUploadForm = new FormGroup({
|
|
||||||
filename: new FormControl('', {
|
|
||||||
validators: Validators.required,
|
|
||||||
asyncValidators: this.imageDoesNotExistValidator.validator(),
|
|
||||||
updateOn: 'blur',
|
|
||||||
}),
|
|
||||||
alt: new FormControl(''),
|
|
||||||
caption: new FormControl(''),
|
|
||||||
isPublic: new FormControl(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
private readonly imageService = inject(ImageService);
|
|
||||||
|
|
||||||
protected onImageFileEvent(event: FileUploadEvent): void {
|
|
||||||
if (event._tag === 'file-add-event') {
|
|
||||||
this.fileToUpload.set(event.file);
|
|
||||||
this.imageUploadForm.controls.filename.setValue(event.file.name);
|
|
||||||
this.imageUploadForm.controls.filename.markAsTouched();
|
|
||||||
} else {
|
|
||||||
this.fileToUpload.set(null);
|
|
||||||
this.imageUploadForm.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async onSubmit(): Promise<void> {
|
|
||||||
this.inProgress.set(true);
|
|
||||||
const formValue = this.imageUploadForm.value;
|
|
||||||
let hadError = false;
|
|
||||||
try {
|
|
||||||
await this.imageService.uploadImage(
|
|
||||||
this.fileToUpload()!, // submit disabled if this is null
|
|
||||||
formValue.filename ?? undefined,
|
|
||||||
formValue.alt ?? undefined,
|
|
||||||
formValue.caption ?? undefined,
|
|
||||||
formValue.isPublic ?? undefined,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
this.error.set('There was an error during upload. Try again.');
|
|
||||||
hadError = true;
|
|
||||||
}
|
|
||||||
this.inProgress.set(false);
|
|
||||||
if (!hadError) {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly faFileImage = faFileImage;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<app-dialog-container title="Edit Ingredient">
|
|
||||||
<form (submit)="onSubmit($event)">
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Amount</mat-label>
|
|
||||||
<input matInput [formControl]="ingredientForm.controls.amount" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Name</mat-label>
|
|
||||||
<input matInput [formControl]="ingredientForm.controls.name" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Notes</mat-label>
|
|
||||||
<input matInput [formControl]="ingredientForm.controls.notes" />
|
|
||||||
</mat-form-field>
|
|
||||||
<button matButton="filled" type="submit" [disabled]="ingredientForm.invalid">Submit</button>
|
|
||||||
</form>
|
|
||||||
</app-dialog-container>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { IngredientDialog } from './ingredient-dialog';
|
|
||||||
|
|
||||||
describe('IngredientDialog', () => {
|
|
||||||
let component: IngredientDialog;
|
|
||||||
let fixture: ComponentFixture<IngredientDialog>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [IngredientDialog],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(IngredientDialog);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { Component, inject, OnInit } from '@angular/core';
|
|
||||||
import { DialogContainer } from '../../dialog-container/dialog-container';
|
|
||||||
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { IngredientDraftClientModel } from '../../../client-models/IngredientDraftClientModel';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-ingredient-dialog',
|
|
||||||
imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton],
|
|
||||||
templateUrl: './ingredient-dialog.html',
|
|
||||||
styleUrl: './ingredient-dialog.css',
|
|
||||||
})
|
|
||||||
export class IngredientDialog implements OnInit {
|
|
||||||
public readonly dialogRef = inject(MatDialogRef);
|
|
||||||
private readonly model = inject<IngredientDraftClientModel>(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
protected readonly ingredientForm = new FormGroup({
|
|
||||||
amount: new FormControl(''),
|
|
||||||
name: new FormControl('', Validators.required),
|
|
||||||
notes: new FormControl(''),
|
|
||||||
});
|
|
||||||
|
|
||||||
public ngOnInit(): void {
|
|
||||||
this.ingredientForm.patchValue({
|
|
||||||
amount: this.model.draft.amount,
|
|
||||||
name: this.model.draft.name,
|
|
||||||
notes: this.model.draft.notes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onSubmit(event: SubmitEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
this.dialogRef.close({
|
|
||||||
...this.model,
|
|
||||||
draft: {
|
|
||||||
amount: this.ingredientForm.value.amount,
|
|
||||||
name: this.ingredientForm.value.name,
|
|
||||||
notes: this.ingredientForm.value.notes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 60ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: auto;
|
|
||||||
overflow: hidden;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.times-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-column-reorder {
|
|
||||||
width: 32px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-column-actions {
|
|
||||||
width: 32px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
<form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)">
|
|
||||||
<h3>Basic Info</h3>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Title</mat-label>
|
|
||||||
<input matInput [formControl]="recipeFormGroup.controls.title" />
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Slug</mat-label>
|
|
||||||
<input matInput [formControl]="recipeFormGroup.controls.slug" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<div class="ingredients-container">
|
|
||||||
<h3>Ingredients</h3>
|
|
||||||
|
|
||||||
<table
|
|
||||||
#ingredientsTable
|
|
||||||
mat-table
|
|
||||||
[dataSource]="ingredientModels()"
|
|
||||||
cdkDropList
|
|
||||||
(cdkDropListDropped)="onIngredientDrop($event)"
|
|
||||||
>
|
|
||||||
<ng-container matColumnDef="reorder">
|
|
||||||
<th mat-header-cell *matHeaderCellDef></th>
|
|
||||||
<td mat-cell *matCellDef>
|
|
||||||
<fa-icon [icon]="faBars"></fa-icon>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="amount">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
|
||||||
<td mat-cell *matCellDef="let model">
|
|
||||||
{{ model.draft.amount }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="name">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
|
||||||
<td mat-cell *matCellDef="let model">
|
|
||||||
{{ model.draft.name }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="notes">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Notes</th>
|
|
||||||
<td mat-cell *matCellDef="let model">
|
|
||||||
{{ model.draft.notes }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
|
||||||
<td mat-cell *matCellDef="let model">
|
|
||||||
<button matButton="text" [matMenuTriggerFor]="ingredientActionsMenu" type="button">
|
|
||||||
<fa-icon [icon]="faEllipsis" size="2x"></fa-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #ingredientActionsMenu="matMenu">
|
|
||||||
<button mat-menu-item (click)="editIngredient(model)" type="button">Edit</button>
|
|
||||||
<button mat-menu-item (click)="onIngredientDelete(model)" type="button">Delete</button>
|
|
||||||
</mat-menu>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="['reorder', 'amount', 'name', 'notes', 'actions']"></tr>
|
|
||||||
<tr
|
|
||||||
mat-row
|
|
||||||
*matRowDef="let row; columns: ['reorder', 'amount', 'name', 'notes', 'actions']"
|
|
||||||
cdkDrag
|
|
||||||
[cdkDragData]="row"
|
|
||||||
></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Images</h3>
|
|
||||||
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
|
|
||||||
|
|
||||||
<h4>Select Main Image</h4>
|
|
||||||
<app-image-select
|
|
||||||
(select)="onMainImageSelect($event)"
|
|
||||||
[selectedUsernameFilename]="mainImageUsernameFilename()"
|
|
||||||
></app-image-select>
|
|
||||||
|
|
||||||
<div class="times-container">
|
|
||||||
<h3>Times</h3>
|
|
||||||
<p>Enter all as number of minutes, <em>eg.</em> 45</p>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Preparation Time (minutes)</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[formControl]="recipeFormGroup.controls.preparationTime"
|
|
||||||
data-test-role="preparation-time-input"
|
|
||||||
/>
|
|
||||||
@if (recipeFormGroup.controls.preparationTime.hasError("pattern")) {
|
|
||||||
<mat-error data-test-role="preparation-time-error">Must be a valid number.</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Cooking Time (minutes)</mat-label>
|
|
||||||
<input matInput [formControl]="recipeFormGroup.controls.cookingTime" data-test-role="cooking-time-input" />
|
|
||||||
@if (recipeFormGroup.controls.cookingTime.hasError("pattern")) {
|
|
||||||
<mat-error data-test-role="cooking-time-error">Must be a valid number.</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Total Time (minutes)</mat-label>
|
|
||||||
<input matInput [formControl]="recipeFormGroup.controls.totalTime" data-test-role="total-time-input" />
|
|
||||||
@if (recipeFormGroup.controls.totalTime.hasError("pattern")) {
|
|
||||||
<mat-error data-test-role="total-time-error">Must be a valid number.</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Recipe Text</h3>
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>Recipe Text</mat-label>
|
|
||||||
<textarea
|
|
||||||
#recipeTextTextarea
|
|
||||||
matInput
|
|
||||||
[formControl]="recipeFormGroup.controls.text"
|
|
||||||
(input)="onRecipeTextChange($event)"
|
|
||||||
></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
<button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Submit</button>
|
|
||||||
</form>
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
import { RecipeEditForm } from './recipe-edit-form';
|
|
||||||
import { By } from '@angular/platform-browser';
|
|
||||||
import { inputBinding } from '@angular/core';
|
|
||||||
import { UserInfoView } from '../../models/UserInfoView.model';
|
|
||||||
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
|
|
||||||
import { ImageService } from '../../services/ImageService';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { SliceView, SliceViewMeta } from '../../models/SliceView.model';
|
|
||||||
import { ImageViewWithBlobUrl } from '../../client-models/ImageViewWithBlobUrl';
|
|
||||||
|
|
||||||
describe('RecipeEditForm', () => {
|
|
||||||
let component: RecipeEditForm;
|
|
||||||
let fixture: ComponentFixture<RecipeEditForm>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const imageServiceMock = {
|
|
||||||
getOwnedImagesCount: vi.fn(() => of(0)),
|
|
||||||
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
|
|
||||||
of({
|
|
||||||
count: 0,
|
|
||||||
slice: {} as SliceViewMeta,
|
|
||||||
content: [],
|
|
||||||
} as SliceView<ImageViewWithBlobUrl>),
|
|
||||||
),
|
|
||||||
} as Partial<ImageService>;
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [RecipeEditForm],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ImageService,
|
|
||||||
useValue: imageServiceMock,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(RecipeEditForm, {
|
|
||||||
bindings: [
|
|
||||||
inputBinding(
|
|
||||||
'recipe',
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
id: 'test-id',
|
|
||||||
created: new Date(),
|
|
||||||
state: 'ENTER_DATA',
|
|
||||||
owner: {} as UserInfoView,
|
|
||||||
}) satisfies RecipeDraftViewModel,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const testTimeInput = (describeBlockName: string, inputRole: string, errorRole: string) => {
|
|
||||||
describe(describeBlockName, () => {
|
|
||||||
it('should accept a number input with no error presented', () => {
|
|
||||||
const preparationTimeInputDebug = fixture.debugElement.query(By.css(`[data-test-role=${inputRole}]`));
|
|
||||||
expect(preparationTimeInputDebug).toBeTruthy();
|
|
||||||
const preparationTimeInput: HTMLInputElement = preparationTimeInputDebug.nativeElement;
|
|
||||||
preparationTimeInput.value = '1234';
|
|
||||||
preparationTimeInput.dispatchEvent(new Event('input'));
|
|
||||||
preparationTimeInput.dispatchEvent(new Event('blur'));
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not output an error if touched but no input', () => {
|
|
||||||
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
|
|
||||||
By.css(`[data-test-role=${inputRole}]`),
|
|
||||||
).nativeElement;
|
|
||||||
preparationTimeInput.dispatchEvent(new Event('blur'));
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display an error if non-number input', () => {
|
|
||||||
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
|
|
||||||
By.css(`[data-test-role=${inputRole}]`),
|
|
||||||
).nativeElement;
|
|
||||||
preparationTimeInput.value = 'abcd';
|
|
||||||
preparationTimeInput.dispatchEvent(new Event('input'));
|
|
||||||
preparationTimeInput.dispatchEvent(new Event('blur'));
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const errorDebug = fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`));
|
|
||||||
expect(errorDebug).toBeTruthy();
|
|
||||||
expect(errorDebug.nativeElement.textContent).toContain('Must be a valid number.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('time inputs', () => {
|
|
||||||
testTimeInput('preparation time', 'preparation-time-input', 'preparation-time-error');
|
|
||||||
testTimeInput('cooking time', 'cooking-time-input', 'cooking-time-error');
|
|
||||||
testTimeInput('total time', 'total-time-input', 'total-time-error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,255 +0,0 @@
|
|||||||
import {
|
|
||||||
afterNextRender,
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
ElementRef,
|
|
||||||
inject,
|
|
||||||
Injector,
|
|
||||||
input,
|
|
||||||
OnInit,
|
|
||||||
output,
|
|
||||||
runInInjectionContext,
|
|
||||||
signal,
|
|
||||||
viewChild,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { ImageSelect } from './image-select/image-select';
|
|
||||||
import { MatButton } from '@angular/material/button';
|
|
||||||
import {
|
|
||||||
MatCell,
|
|
||||||
MatCellDef,
|
|
||||||
MatColumnDef,
|
|
||||||
MatHeaderCell,
|
|
||||||
MatHeaderCellDef,
|
|
||||||
MatHeaderRow,
|
|
||||||
MatHeaderRowDef,
|
|
||||||
MatRow,
|
|
||||||
MatRowDef,
|
|
||||||
MatTable,
|
|
||||||
} from '@angular/material/table';
|
|
||||||
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { IngredientDraftClientModel } from '../../client-models/IngredientDraftClientModel';
|
|
||||||
import { ImageView } from '../../models/ImageView.model';
|
|
||||||
import { IngredientDialog } from './ingredient-dialog/ingredient-dialog';
|
|
||||||
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
|
|
||||||
import { RecipeEditFormSubmitEvent } from './RecipeEditFormSubmitEvent';
|
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog';
|
|
||||||
import { FullRecipeView } from '../../models/Recipe.model';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-recipe-edit-form',
|
|
||||||
imports: [
|
|
||||||
CdkDrag,
|
|
||||||
CdkDropList,
|
|
||||||
FaIconComponent,
|
|
||||||
ImageSelect,
|
|
||||||
MatButton,
|
|
||||||
MatCell,
|
|
||||||
MatCellDef,
|
|
||||||
MatColumnDef,
|
|
||||||
MatError,
|
|
||||||
MatFormField,
|
|
||||||
MatHeaderCell,
|
|
||||||
MatHeaderRow,
|
|
||||||
MatHeaderRowDef,
|
|
||||||
MatInput,
|
|
||||||
MatLabel,
|
|
||||||
MatMenu,
|
|
||||||
MatMenuItem,
|
|
||||||
MatRow,
|
|
||||||
MatRowDef,
|
|
||||||
MatTable,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
MatMenuTrigger,
|
|
||||||
MatHeaderCellDef,
|
|
||||||
],
|
|
||||||
templateUrl: './recipe-edit-form.html',
|
|
||||||
styleUrl: './recipe-edit-form.css',
|
|
||||||
})
|
|
||||||
export class RecipeEditForm implements OnInit {
|
|
||||||
public readonly recipe = input.required<RecipeDraftViewModel | FullRecipeView>();
|
|
||||||
public readonly editSlugDisabled = input<boolean>(false);
|
|
||||||
public readonly submitRecipe = output<RecipeEditFormSubmitEvent>();
|
|
||||||
public readonly deleteDraft = output<void>();
|
|
||||||
|
|
||||||
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
|
||||||
|
|
||||||
private readonly dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
protected readonly recipeFormGroup = new FormGroup({
|
|
||||||
title: new FormControl('', Validators.required),
|
|
||||||
slug: new FormControl('', Validators.required),
|
|
||||||
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
|
||||||
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
|
||||||
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
|
||||||
text: new FormControl('', Validators.required),
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
|
||||||
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
|
||||||
|
|
||||||
private readonly mainImage = signal<ImageView | null>(null);
|
|
||||||
protected readonly mainImageUsernameFilename = computed(() => {
|
|
||||||
const mainImage = this.mainImage();
|
|
||||||
if (mainImage) {
|
|
||||||
return [mainImage.owner.username, mainImage.filename] as const;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private readonly injector = inject(Injector);
|
|
||||||
|
|
||||||
public ngOnInit(): void {
|
|
||||||
if (this.editSlugDisabled()) {
|
|
||||||
this.recipeFormGroup.controls.slug.disable();
|
|
||||||
}
|
|
||||||
const draft = this.recipe();
|
|
||||||
this.recipeFormGroup.patchValue({
|
|
||||||
title: draft.title ?? '',
|
|
||||||
slug: draft.slug ?? '',
|
|
||||||
preparationTime: draft.preparationTime?.toString() ?? '',
|
|
||||||
cookingTime: draft.cookingTime?.toString() ?? '',
|
|
||||||
totalTime: draft.totalTime?.toString() ?? '',
|
|
||||||
text: draft.rawText ?? '',
|
|
||||||
});
|
|
||||||
if (draft.ingredients) {
|
|
||||||
this.ingredientModels.set(
|
|
||||||
draft.ingredients.map((ingredient, index) => ({
|
|
||||||
id: index,
|
|
||||||
draft: ingredient,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (draft.mainImage) {
|
|
||||||
this.mainImage.set(draft.mainImage);
|
|
||||||
}
|
|
||||||
runInInjectionContext(this.injector, () => {
|
|
||||||
afterNextRender({
|
|
||||||
mixedReadWrite: () => {
|
|
||||||
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
|
|
||||||
const windowScrollX = window.scrollX;
|
|
||||||
const windowScrollY = window.scrollY;
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
window.scrollTo(windowScrollX, windowScrollY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onRecipeTextChange(event: Event): void {
|
|
||||||
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected addIngredient(): void {
|
|
||||||
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
|
||||||
IngredientDialog,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
id: this.ingredientModels().length,
|
|
||||||
draft: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
dialogRef.afterClosed().subscribe((ingredientModel) => {
|
|
||||||
if (ingredientModel) {
|
|
||||||
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
|
|
||||||
this.ingredientsTable()!.renderRows();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected editIngredient(model: IngredientDraftClientModel): void {
|
|
||||||
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
|
||||||
IngredientDialog,
|
|
||||||
{
|
|
||||||
data: model,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
dialogRef.afterClosed().subscribe((model) => {
|
|
||||||
if (model) {
|
|
||||||
this.ingredientModels.update((models) => {
|
|
||||||
const updated: IngredientDraftClientModel[] = [...models];
|
|
||||||
const target = updated.find((search) => search.id === model.id);
|
|
||||||
if (!target) {
|
|
||||||
throw new Error('Ingredient does not exist.');
|
|
||||||
}
|
|
||||||
target.draft = model.draft;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
|
|
||||||
this.ingredientModels.update((ingredientModels) => {
|
|
||||||
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
|
|
||||||
updated.sort((model0, model1) => model0.id - model1.id);
|
|
||||||
updated.forEach((model, index) => {
|
|
||||||
model.id = index;
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
|
|
||||||
this.ingredientModels.update((ingredientModels) => {
|
|
||||||
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
|
|
||||||
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
|
|
||||||
ingredientModels.forEach((model, index) => {
|
|
||||||
model.id = index;
|
|
||||||
});
|
|
||||||
return ingredientModels;
|
|
||||||
});
|
|
||||||
this.ingredientsTable()!.renderRows();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTime(s?: string | null): number | null {
|
|
||||||
if (!s) return null;
|
|
||||||
try {
|
|
||||||
return parseInt(s);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Should not have had a parse error because of form validators: ${e}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onSubmit(event: SubmitEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const value = this.recipeFormGroup.value;
|
|
||||||
this.submitRecipe.emit({
|
|
||||||
title: value.title!,
|
|
||||||
slug: value.slug!,
|
|
||||||
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
|
|
||||||
preparationTime: this.getTime(value.preparationTime),
|
|
||||||
cookingTime: this.getTime(value.cookingTime),
|
|
||||||
totalTime: this.getTime(value.totalTime),
|
|
||||||
mainImage: this.mainImage(),
|
|
||||||
rawText: value.text!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected openImageUploadDialog(): void {
|
|
||||||
this.dialog.open(ImageUploadDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMainImageSelect(imageView: ImageView | null): void {
|
|
||||||
this.mainImage.set(imageView);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly faEllipsis = faEllipsis;
|
|
||||||
protected readonly faBars = faBars;
|
|
||||||
}
|
|
||||||
@ -1,3 +1,3 @@
|
|||||||
@if (enabled()) {
|
@if (enabled()) {
|
||||||
<span class="loader" [style]="{ width: size(), height: size() }"></span>
|
<span class="loader"></span>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,4 @@ import { Component, input } from '@angular/core';
|
|||||||
})
|
})
|
||||||
export class Spinner {
|
export class Spinner {
|
||||||
public readonly enabled = input(true);
|
public readonly enabled = input(true);
|
||||||
public readonly size = input('48px');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { CanActivateFn } from '@angular/router';
|
|
||||||
|
|
||||||
import { authGuard } from './auth-guard';
|
|
||||||
|
|
||||||
describe('authGuardGuard', () => {
|
|
||||||
const executeGuard: CanActivateFn = (...guardParameters) =>
|
|
||||||
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(executeGuard).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { CanActivateFn, RedirectCommand, Router } from '@angular/router';
|
|
||||||
import { inject } from '@angular/core';
|
|
||||||
import { AuthService } from '../services/AuthService';
|
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = (route, state) => {
|
|
||||||
const authService = inject(AuthService);
|
|
||||||
if (authService.accessToken() === null) {
|
|
||||||
const router = inject(Router);
|
|
||||||
return new RedirectCommand(router.parseUrl('/'));
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,15 +1,10 @@
|
|||||||
import { UserInfoView } from './UserInfoView.model';
|
import { ResourceOwner } from './ResourceOwner.model';
|
||||||
|
|
||||||
export interface ImageView {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { UserInfoView } from './UserInfoView.model';
|
|
||||||
import { ImageView } from './ImageView.model';
|
|
||||||
|
|
||||||
export interface RecipeDraftViewModel {
|
|
||||||
id: string;
|
|
||||||
created: Date;
|
|
||||||
modified?: Date | null;
|
|
||||||
state: 'INFER' | 'ENTER_DATA';
|
|
||||||
slug?: string | null;
|
|
||||||
title?: string | null;
|
|
||||||
preparationTime?: number | null;
|
|
||||||
cookingTime?: number | null;
|
|
||||||
totalTime?: number | null;
|
|
||||||
rawText?: string | null;
|
|
||||||
ingredients?: IngredientDraft[] | null;
|
|
||||||
owner: UserInfoView;
|
|
||||||
mainImage?: ImageView | null;
|
|
||||||
lastInference?: RecipeDraftInferenceView | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IngredientDraft {
|
|
||||||
amount?: string | null;
|
|
||||||
name: string;
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeDraftInferenceView {
|
|
||||||
inferredAt: Date;
|
|
||||||
title: string;
|
|
||||||
rawText: string;
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
export interface UserInfoView {
|
export interface ResourceOwner {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export interface SetImageBody {
|
|
||||||
username: string;
|
|
||||||
userFilename: string;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user