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;
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"
/>
}
@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>
<app-recipe-comments-list [recipeUsername]="recipe.owner.username" [recipeSlug]="recipe.slug" />
</article>

View File

@ -1,5 +1,5 @@
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 { ImageService } from '../../../shared/services/ImageService';
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',
})
export class RecipePageContent {
public recipeView = input.required<RecipeView>();
public recipeView = input.required<FullRecipeViewWrapper>();
private readonly imageService = inject(ImageService);
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 { inputBinding } from '@angular/core';
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 { of } from 'rxjs';
import { SliceView, SliceViewMeta } from '../../../../shared/models/SliceView.model';
@ -54,7 +54,7 @@ describe('EnterRecipeData', () => {
id: 'test-id',
created: new Date(),
state: 'ENTER_DATA',
owner: {} as ResourceOwner,
owner: {} as UserInfoView,
}) satisfies RecipeDraftViewModel,
),
],

View File

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

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 { injectQuery } from '@tanstack/angular-query-experimental';
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
import { RecipeInfoView } from '../../shared/models/Recipe.model';
import { Spinner } from '../../shared/components/spinner/spinner';
@Component({
selector: 'app-recipes-page',
imports: [RecipeCardGrid],
imports: [RecipeCardGrid, Spinner],
templateUrl: './recipes-page.html',
styleUrl: './recipes-page.css',
})
export class RecipesPage {
export class RecipesPage implements OnInit {
private readonly recipeService = inject(RecipeService);
protected readonly recipes = injectQuery(() => ({
queryKey: ['recipes'],
queryFn: () => this.recipeService.getRecipes(),
}));
protected readonly loadingRecipes = signal(false);
protected readonly loadRecipesError = signal<Error | null>(null);
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 { RecipeCard } from './recipe-card/recipe-card';
import { Recipe } from '../../models/Recipe.model';
import { RecipeInfoView } from '../../models/Recipe.model';
@Component({
selector: 'app-recipe-card-grid',
@ -9,5 +9,5 @@ import { Recipe } from '../../models/Recipe.model';
styleUrl: './recipe-card-grid.css',
})
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 { Recipe } from '../../../models/Recipe.model';
import { RecipeInfoView } from '../../../models/Recipe.model';
import { RouterLink } from '@angular/router';
import { CreateQueryOptions, injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../services/ImageService';
@ -16,7 +16,7 @@ import { Spinner } from '../../spinner/spinner';
styleUrl: './recipe-card.css',
})
export class RecipeCard {
public recipe = input.required<Recipe>();
public recipe = input.required<RecipeInfoView>();
protected readonly recipePageLink = computed(() => {
const recipe = this.recipe();

View File

@ -1,4 +1,4 @@
import { ResourceOwner } from './ResourceOwner.model';
import { UserInfoView } from './UserInfoView.model';
export interface ImageView {
url: string;
@ -8,7 +8,7 @@ export interface ImageView {
mimeType: string;
alt?: string | null;
caption?: string | null;
owner: ResourceOwner;
owner: UserInfoView;
isPublic?: boolean;
height: 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';
export interface RecipeInfoViews {
slice: {
number: number;
size: number;
};
content: Recipe[];
export interface RecipeInfoView {
id: number;
created: Date;
modified?: Date | null;
slug: string;
title: string;
preparationTime?: number | null;
cookingTime?: number | null;
totalTime?: number | null;
owner: UserInfoView;
isPublic: boolean;
starCount: number;
mainImage?: ImageView | null;
}
export interface RecipeView {
export interface FullRecipeViewWrapper {
isOwner: boolean | null;
isStarred: boolean | null;
recipe: Recipe;
recipe: FullRecipeView;
}
export interface Recipe {
export interface FullRecipeView {
id: number;
isPublic: boolean;
mainImage?: ImageView | null;
owner: ResourceOwner;
created: Date;
modified?: Date | null;
slug: string;
starCount: number;
text: 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 {
id: number;
@ -6,6 +6,6 @@ export interface RecipeComment {
modified: string | null;
text: string;
rawText: string | null;
owner: ResourceOwner;
owner: UserInfoView;
recipeId: number;
}

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { RecipeUploadStep } from '../client-models/RecipeUploadStep';
import { RecipeDraftViewModel } from '../models/RecipeDraftView.model';
import { EndpointService } from './EndpointService';
import { WithStringDates } from '../util';
import { Recipe } from '../models/Recipe.model';
import { FullRecipeView } from '../models/Recipe.model';
import { ImageView } from '../models/ImageView.model';
import { SetImageBody } from '../models/SetImageBody';
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(
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 { HttpClient } from '@angular/common/http';
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 { QueryClient } from '@tanstack/angular-query-experimental';
import { RecipeComment } from '../models/RecipeComment.model';
import { QueryParams } from '../models/Query.model';
import { EndpointService } from './EndpointService';
import { SliceView } from '../models/SliceView.model';
import { WithStringDates } from '../util';
import { ImageService } from './ImageService';
@Injectable({
providedIn: 'root',
@ -17,22 +19,39 @@ export class RecipeService {
private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient);
private readonly endpointService = inject(EndpointService);
private readonly imageService = inject(ImageService);
public getRecipes(): Promise<Recipe[]> {
return firstValueFrom(
this.http.get<RecipeInfoViews>(this.endpointService.getUrl('recipes')).pipe(map((res) => res.content)),
private hydrateRecipeInfoView(withStringDates: WithStringDates<RecipeInfoView>): RecipeInfoView {
return {
...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> {
return firstValueFrom(this.http.get<RecipeView>(this.endpointService.getUrl('recipes', [username, slug])));
public getRecipeView(username: string, slug: string): Promise<FullRecipeViewWrapper> {
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]);
}
public async toggleStar(recipeView: RecipeView): Promise<void> {
public async toggleStar(recipeView: FullRecipeViewWrapper): Promise<void> {
if (this.authService.accessToken()) {
if (recipeView.isStarred) {
await lastValueFrom(this.http.delete(this.getRecipeBaseUrl(recipeView) + '/star'));
@ -67,9 +86,9 @@ export class RecipeService {
return comment;
}
public async aiSearch(prompt: string): Promise<Recipe[]> {
public async aiSearch(prompt: string): Promise<FullRecipeView[]> {
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',
data: {
prompt,