MME-9 Add ingredients to recipe page. Small refactorings and model changes.

This commit is contained in:
Jesse Brault 2026-02-13 11:03:57 -06:00
parent 09fb9a3d98
commit ac000de6a5
15 changed files with 144 additions and 57 deletions

View File

@ -36,3 +36,16 @@ article {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.ingredients-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
row-gap: 5px;
}
.ingredient {
display: flex;
column-gap: 5px;
}

View File

@ -53,6 +53,26 @@
[width]="recipe.mainImage!.width" [width]="recipe.mainImage!.width"
/> />
} }
@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>
}
<h3>Instructions</h3>
<div [innerHTML]="recipe.text"></div> <div [innerHTML]="recipe.text"></div>
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" /> <app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />
</article> </article>

View File

@ -1,5 +1,5 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { RecipeView } from '../../../shared/models/Recipe.model'; import { FullRecipeViewWrapper } from '../../../shared/models/Recipe.model';
import { CreateQueryOptions, injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; import { CreateQueryOptions, 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 { faEllipsis, faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons';
@ -24,7 +24,7 @@ import { ToastrService } from 'ngx-toastr';
styleUrl: './recipe-page-content.css', styleUrl: './recipe-page-content.css',
}) })
export class RecipePageContent { export class RecipePageContent {
public recipeView = input.required<RecipeView>(); public recipeView = input.required<FullRecipeViewWrapper>();
private readonly imageService = inject(ImageService); private readonly imageService = inject(ImageService);
private readonly recipeService = inject(RecipeService); private readonly recipeService = inject(RecipeService);

View File

@ -3,7 +3,7 @@ import { EnterRecipeData } from './enter-recipe-data';
import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental'; import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental';
import { inputBinding } from '@angular/core'; import { inputBinding } from '@angular/core';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model'; import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
import { ResourceOwner } from '../../../../shared/models/ResourceOwner.model'; import { UserInfoView } from '../../../../shared/models/UserInfoView.model';
import { ImageService } from '../../../../shared/services/ImageService'; import { ImageService } from '../../../../shared/services/ImageService';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { SliceView, SliceViewMeta } from '../../../../shared/models/SliceView.model'; import { SliceView, SliceViewMeta } from '../../../../shared/models/SliceView.model';
@ -54,7 +54,7 @@ describe('EnterRecipeData', () => {
id: 'test-id', id: 'test-id',
created: new Date(), created: new Date(),
state: 'ENTER_DATA', state: 'ENTER_DATA',
owner: {} as ResourceOwner, owner: {} as UserInfoView,
}) satisfies RecipeDraftViewModel, }) satisfies RecipeDraftViewModel,
), ),
], ],

View File

@ -1,8 +1,8 @@
<h1>Recipes</h1> <h1>Recipes</h1>
@if (recipes.isSuccess()) { @if (loadingRecipes()) {
<app-recipe-card-grid [recipes]="recipes.data()" /> <app-spinner></app-spinner>
} @else if (recipes.isLoading()) { } @else if (loadRecipesError()) {
<p>Loading...</p> <p>There was an error loading recipes: {{ loadRecipesError() }}</p>
} @else if (recipes.isError()) { } @else {
<p>{{ recipes.error().message }}</p> <app-recipe-card-grid [recipes]="recipes()"></app-recipe-card-grid>
} }

View File

@ -1,19 +1,33 @@
import { Component, inject } from '@angular/core'; import { Component, inject, OnInit, signal } 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';
@Component({ @Component({
selector: 'app-recipes-page', selector: 'app-recipes-page',
imports: [RecipeCardGrid], imports: [RecipeCardGrid, Spinner],
templateUrl: './recipes-page.html', templateUrl: './recipes-page.html',
styleUrl: './recipes-page.css', styleUrl: './recipes-page.css',
}) })
export class RecipesPage { export class RecipesPage implements OnInit {
private readonly recipeService = inject(RecipeService); private readonly recipeService = inject(RecipeService);
protected readonly recipes = injectQuery(() => ({ protected readonly loadingRecipes = signal(false);
queryKey: ['recipes'], protected readonly loadRecipesError = signal<Error | null>(null);
queryFn: () => this.recipeService.getRecipes(), protected readonly recipes = signal<RecipeInfoView[]>([]);
}));
public ngOnInit(): void {
this.loadingRecipes.set(true);
this.recipeService.getRecipes().subscribe({
next: (sliceView) => {
this.loadingRecipes.set(false);
this.recipes.set(sliceView.content);
},
error: (e) => {
this.loadingRecipes.set(false);
this.loadRecipesError.set(e);
},
});
}
} }

View File

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

View File

@ -1,5 +1,5 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { Recipe } from '../../../models/Recipe.model'; import { RecipeInfoView } from '../../../models/Recipe.model';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { CreateQueryOptions, injectQuery } from '@tanstack/angular-query-experimental'; import { CreateQueryOptions, injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../services/ImageService'; import { ImageService } from '../../../services/ImageService';
@ -16,7 +16,7 @@ import { Spinner } from '../../spinner/spinner';
styleUrl: './recipe-card.css', styleUrl: './recipe-card.css',
}) })
export class RecipeCard { export class RecipeCard {
public recipe = input.required<Recipe>(); public recipe = input.required<RecipeInfoView>();
protected readonly recipePageLink = computed(() => { protected readonly recipePageLink = computed(() => {
const recipe = this.recipe(); const recipe = this.recipe();

View File

@ -1,4 +1,4 @@
import { ResourceOwner } from './ResourceOwner.model'; import { UserInfoView } from './UserInfoView.model';
export interface ImageView { export interface ImageView {
url: string; url: string;
@ -8,7 +8,7 @@ export interface ImageView {
mimeType: string; mimeType: string;
alt?: string | null; alt?: string | null;
caption?: string | null; caption?: string | null;
owner: ResourceOwner; owner: UserInfoView;
isPublic?: boolean; isPublic?: boolean;
height: number | null; height: number | null;
width: number | null; width: number | null;

View File

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

View File

@ -1,4 +1,4 @@
import { ResourceOwner } from './ResourceOwner.model'; import { UserInfoView } from './UserInfoView.model';
export interface RecipeComment { export interface RecipeComment {
id: number; id: number;
@ -6,6 +6,6 @@ export interface RecipeComment {
modified: string | null; modified: string | null;
text: string; text: string;
rawText: string | null; rawText: string | null;
owner: ResourceOwner; owner: UserInfoView;
recipeId: number; recipeId: number;
} }

View File

@ -1,4 +1,4 @@
import { ResourceOwner } from './ResourceOwner.model'; import { UserInfoView } from './UserInfoView.model';
import { ImageView } from './ImageView.model'; import { ImageView } from './ImageView.model';
export interface RecipeDraftViewModel { export interface RecipeDraftViewModel {
@ -13,7 +13,7 @@ export interface RecipeDraftViewModel {
totalTime?: number | null; totalTime?: number | null;
rawText?: string | null; rawText?: string | null;
ingredients?: IngredientDraft[] | null; ingredients?: IngredientDraft[] | null;
owner: ResourceOwner; owner: UserInfoView;
mainImage?: ImageView | null; mainImage?: ImageView | null;
lastInference?: RecipeDraftInferenceView | null; lastInference?: RecipeDraftInferenceView | null;
} }

View File

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

View File

@ -6,7 +6,7 @@ import { RecipeUploadStep } from '../client-models/RecipeUploadStep';
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model'; import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
import { EndpointService } from './EndpointService'; import { EndpointService } from './EndpointService';
import { WithStringDates } from '../util'; import { WithStringDates } from '../util';
import { Recipe } from '../models/Recipe.model'; import { FullRecipeView } from '../models/Recipe.model';
import { ImageView } from '../models/ImageView.model'; import { ImageView } from '../models/ImageView.model';
import { SetImageBody } from '../models/SetImageBody'; import { SetImageBody } from '../models/SetImageBody';
import { ImageService } from './ImageService'; import { ImageService } from './ImageService';
@ -112,9 +112,9 @@ export class RecipeDraftService {
); );
} }
public publish(id: string): Promise<Recipe> { public publish(id: string): Promise<FullRecipeView> {
return firstValueFrom( return firstValueFrom(
this.http.post<Recipe>(this.endpointService.getUrl('recipeDrafts', [id, 'publish']), null), this.http.post<FullRecipeView>(this.endpointService.getUrl('recipeDrafts', [id, 'publish']), null),
); );
} }

View File

@ -1,13 +1,15 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom, lastValueFrom, map, Observable } from 'rxjs'; import { firstValueFrom, lastValueFrom, map, Observable } from 'rxjs';
import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model'; import { FullRecipeView, FullRecipeViewWrapper, RecipeInfoView } from '../models/Recipe.model';
import { AuthService } from './AuthService'; import { AuthService } from './AuthService';
import { QueryClient } from '@tanstack/angular-query-experimental'; import { QueryClient } from '@tanstack/angular-query-experimental';
import { RecipeComment } from '../models/RecipeComment.model'; import { RecipeComment } from '../models/RecipeComment.model';
import { QueryParams } from '../models/Query.model'; import { QueryParams } from '../models/Query.model';
import { EndpointService } from './EndpointService'; import { EndpointService } from './EndpointService';
import { SliceView } from '../models/SliceView.model'; import { SliceView } from '../models/SliceView.model';
import { WithStringDates } from '../util';
import { ImageService } from './ImageService';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -17,22 +19,39 @@ export class RecipeService {
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient); private readonly queryClient = inject(QueryClient);
private readonly endpointService = inject(EndpointService); private readonly endpointService = inject(EndpointService);
private readonly imageService = inject(ImageService);
public getRecipes(): Promise<Recipe[]> { private hydrateRecipeInfoView(withStringDates: WithStringDates<RecipeInfoView>): RecipeInfoView {
return firstValueFrom( return {
this.http.get<RecipeInfoViews>(this.endpointService.getUrl('recipes')).pipe(map((res) => res.content)), ...withStringDates,
created: new Date(withStringDates.created),
modified: withStringDates.modified ? new Date(withStringDates.modified) : undefined,
mainImage: withStringDates.mainImage
? this.imageService.hydrateImageView(withStringDates.mainImage)
: undefined,
};
}
public getRecipes(): Observable<SliceView<RecipeInfoView>> {
return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe(
map((sliceView) => ({
...sliceView,
content: sliceView.content.map((withStringDates) => this.hydrateRecipeInfoView(withStringDates)),
})),
); );
} }
public getRecipeView(username: string, slug: string): Promise<RecipeView> { public getRecipeView(username: string, slug: string): Promise<FullRecipeViewWrapper> {
return firstValueFrom(this.http.get<RecipeView>(this.endpointService.getUrl('recipes', [username, slug]))); return firstValueFrom(
this.http.get<FullRecipeViewWrapper>(this.endpointService.getUrl('recipes', [username, slug])),
);
} }
private getRecipeBaseUrl(recipeView: RecipeView): string { private getRecipeBaseUrl(recipeView: FullRecipeViewWrapper): string {
return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]); return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]);
} }
public async toggleStar(recipeView: RecipeView): Promise<void> { public async toggleStar(recipeView: FullRecipeViewWrapper): Promise<void> {
if (this.authService.accessToken()) { if (this.authService.accessToken()) {
if (recipeView.isStarred) { if (recipeView.isStarred) {
await lastValueFrom(this.http.delete(this.getRecipeBaseUrl(recipeView) + '/star')); await lastValueFrom(this.http.delete(this.getRecipeBaseUrl(recipeView) + '/star'));
@ -67,9 +86,9 @@ export class RecipeService {
return comment; return comment;
} }
public async aiSearch(prompt: string): Promise<Recipe[]> { public async aiSearch(prompt: string): Promise<FullRecipeView[]> {
const recipeInfoViews = await firstValueFrom( const recipeInfoViews = await firstValueFrom(
this.http.post<{ results: Recipe[] }>(this.endpointService.getUrl('recipes'), { this.http.post<{ results: FullRecipeView[] }>(this.endpointService.getUrl('recipes'), {
type: 'AI_PROMPT', type: 'AI_PROMPT',
data: { data: {
prompt, prompt,