|
|
|
|
@@ -1,7 +1,11 @@
|
|
|
|
|
import { Component, Input, computed, signal } from '@angular/core';
|
|
|
|
|
import { TranslatePipe } from '@ngx-translate/core';
|
|
|
|
|
import { RecipeDetail } from '../../core/models/recipe.models';
|
|
|
|
|
import { Component, EventEmitter, Input, OnChanges, Output, computed, inject, signal } from '@angular/core';
|
|
|
|
|
import { NgClass } from '@angular/common';
|
|
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
|
|
|
import { RecipeManagementService } from '../../core/services/recipe-management.service';
|
|
|
|
|
import { RecipeDetail, RecalculateRecipeCaloriesResult } from '../../core/models/recipe.models';
|
|
|
|
|
import { CategoryBadgeComponent } from '../../shared/components/category-badge.component';
|
|
|
|
|
import { extractApiError } from '../../core/utils/api-error.util';
|
|
|
|
|
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
|
|
|
|
|
|
|
|
|
type IngredientSortColumn = 'name' | 'amount';
|
|
|
|
|
@@ -9,71 +13,84 @@ type IngredientSortColumn = 'name' | 'amount';
|
|
|
|
|
@Component({
|
|
|
|
|
selector: 'app-recipe-detail-view',
|
|
|
|
|
standalone: true,
|
|
|
|
|
imports: [TranslatePipe, CategoryBadgeComponent],
|
|
|
|
|
imports: [NgClass, FormsModule, TranslatePipe, CategoryBadgeComponent],
|
|
|
|
|
template: `
|
|
|
|
|
<article class="space-y-8">
|
|
|
|
|
<header class="space-y-3">
|
|
|
|
|
<app-category-badge [category]="recipe.mealCategory" />
|
|
|
|
|
<h1 class="text-3xl font-extrabold tracking-tight">{{ recipe.name }}</h1>
|
|
|
|
|
@if (recipe.prepTimeMinutes != null) {
|
|
|
|
|
<app-category-badge [category]="displayRecipe().mealCategory" />
|
|
|
|
|
<h1 class="text-3xl font-extrabold tracking-tight">{{ displayRecipe().name }}</h1>
|
|
|
|
|
@if (displayRecipe().prepTimeMinutes != null) {
|
|
|
|
|
<p class="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
|
|
|
|
<span aria-hidden="true">⏱</span>
|
|
|
|
|
{{ 'recipes.prepTime' | translate: { count: recipe.prepTimeMinutes } }}
|
|
|
|
|
{{ 'recipes.prepTime' | translate: { count: displayRecipe().prepTimeMinutes } }}
|
|
|
|
|
</p>
|
|
|
|
|
}
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
|
|
|
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
|
|
|
|
<p class="text-2xl font-extrabold text-orange-500">
|
|
|
|
|
{{ recipe.calories ?? '—' }}
|
|
|
|
|
@if (recipe.calories != null) {
|
|
|
|
|
<span class="text-sm font-semibold"> kcal</span>
|
|
|
|
|
@for (macro of macroCards(); track macro.label) {
|
|
|
|
|
<div
|
|
|
|
|
class="rounded-2xl border px-4 py-3 text-center"
|
|
|
|
|
[ngClass]="
|
|
|
|
|
macro.highlight
|
|
|
|
|
? 'border-brand-300 bg-brand-50 dark:border-brand-700 dark:bg-brand-950/30'
|
|
|
|
|
: 'border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900'
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
<p class="text-2xl font-extrabold" [class]="macro.accent">
|
|
|
|
|
{{ macro.value ?? '—' }}
|
|
|
|
|
@if (macro.value != null) {
|
|
|
|
|
<span class="text-sm font-semibold"> {{ macro.suffix }}</span>
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
|
|
|
{{ macro.label | translate }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="card space-y-3 p-4">
|
|
|
|
|
<h2 class="text-sm font-bold">{{ 'recipes.recalculateCaloriesTitle' | translate }}</h2>
|
|
|
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ 'recipes.recalculateCaloriesHint' | translate }}</p>
|
|
|
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
|
|
|
<label class="block text-sm">
|
|
|
|
|
{{ 'recipes.targetCalories' | translate }}
|
|
|
|
|
<input type="number" min="50" max="10000" class="input mt-1 w-36" [(ngModel)]="targetCalories" (ngModelChange)="preview.set(null)" />
|
|
|
|
|
</label>
|
|
|
|
|
<button type="button" class="btn-secondary" [disabled]="pending() || applyPending()" (click)="runRecalculate(false)">
|
|
|
|
|
↻ {{ 'recipes.recalculate' | translate }}
|
|
|
|
|
</button>
|
|
|
|
|
@if (showingPreview()) {
|
|
|
|
|
<button type="button" class="btn-primary" [disabled]="pending() || applyPending()" (click)="runRecalculate(true)">
|
|
|
|
|
{{ 'recipes.applyRecalculation' | translate }}
|
|
|
|
|
</button>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
@if (showingPreview() && preview()) {
|
|
|
|
|
<p class="text-sm text-brand-700 dark:text-brand-300">
|
|
|
|
|
{{
|
|
|
|
|
'recipes.recalculatePreview'
|
|
|
|
|
| translate: {
|
|
|
|
|
previous: preview()!.previousCalories ?? '—',
|
|
|
|
|
next: preview()!.newCalories ?? '—',
|
|
|
|
|
target: preview()!.targetCalories,
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
@if (preview()!.unlinkedIngredientCount > 0) {
|
|
|
|
|
{{ 'recipes.recalculateUnlinkedHint' | translate: { count: preview()!.unlinkedIngredientCount } }}
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
|
|
|
{{ 'recipes.calories' | translate }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
|
|
|
|
<p class="text-2xl font-extrabold text-brand-600">
|
|
|
|
|
{{ recipe.protein ?? '—' }}
|
|
|
|
|
@if (recipe.protein != null) {
|
|
|
|
|
<span class="text-sm font-semibold"> g</span>
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
|
|
|
{{ 'recipes.protein' | translate }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
|
|
|
|
<p class="text-2xl font-extrabold text-amber-500">
|
|
|
|
|
{{ recipe.fat ?? '—' }}
|
|
|
|
|
@if (recipe.fat != null) {
|
|
|
|
|
<span class="text-sm font-semibold"> g</span>
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
|
|
|
{{ 'recipes.fat' | translate }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
|
|
|
|
<p class="text-2xl font-extrabold text-sky-500">
|
|
|
|
|
{{ recipe.carbs ?? '—' }}
|
|
|
|
|
@if (recipe.carbs != null) {
|
|
|
|
|
<span class="text-sm font-semibold"> g</span>
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
|
|
|
{{ 'recipes.carbs' | translate }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
@if (error()) {
|
|
|
|
|
<p role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error() }}</p>
|
|
|
|
|
}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<div class="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
|
|
|
|
<section>
|
|
|
|
|
<h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2>
|
|
|
|
|
@if (recipe.ingredients.length === 0) {
|
|
|
|
|
@if (displayRecipe().ingredients.length === 0) {
|
|
|
|
|
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noIngredients' | translate }}</p>
|
|
|
|
|
} @else {
|
|
|
|
|
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
|
|
|
|
@@ -89,8 +106,20 @@ type IngredientSortColumn = 'name' | 'amount';
|
|
|
|
|
@for (ing of sortedIngredients(); track ing.id) {
|
|
|
|
|
<li class="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
|
|
|
|
<span>{{ ing.name }}</span>
|
|
|
|
|
<span class="font-medium text-slate-500 dark:text-slate-400">
|
|
|
|
|
<span
|
|
|
|
|
class="font-medium"
|
|
|
|
|
[ngClass]="
|
|
|
|
|
amountChanged(ing.id, ing.amountGrams)
|
|
|
|
|
? 'text-brand-700 dark:text-brand-300'
|
|
|
|
|
: 'text-slate-500 dark:text-slate-400'
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
{{ formatAmount(ing.amountGrams, ing.unit) }}
|
|
|
|
|
@if (amountChanged(ing.id, ing.amountGrams)) {
|
|
|
|
|
<span class="ml-2 text-xs text-slate-400 line-through">
|
|
|
|
|
{{ formatAmount(originalAmounts().get(ing.id) ?? null, ing.unit) }}
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
</span>
|
|
|
|
|
</li>
|
|
|
|
|
}
|
|
|
|
|
@@ -101,11 +130,11 @@ type IngredientSortColumn = 'name' | 'amount';
|
|
|
|
|
|
|
|
|
|
<section>
|
|
|
|
|
<h2 class="text-lg font-bold">{{ 'recipes.preparation' | translate }}</h2>
|
|
|
|
|
@if (recipe.steps.length === 0) {
|
|
|
|
|
@if (displayRecipe().steps.length === 0) {
|
|
|
|
|
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noSteps' | translate }}</p>
|
|
|
|
|
} @else {
|
|
|
|
|
<ol class="mt-4 space-y-4">
|
|
|
|
|
@for (step of recipe.steps; track step.id) {
|
|
|
|
|
@for (step of displayRecipe().steps; track step.id) {
|
|
|
|
|
<li class="flex gap-4">
|
|
|
|
|
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
|
|
|
|
|
{{ step.stepNumber }}
|
|
|
|
|
@@ -122,21 +151,73 @@ type IngredientSortColumn = 'name' | 'amount';
|
|
|
|
|
</article>
|
|
|
|
|
`,
|
|
|
|
|
})
|
|
|
|
|
export class RecipeDetailViewComponent {
|
|
|
|
|
export class RecipeDetailViewComponent implements OnChanges {
|
|
|
|
|
private readonly management = inject(RecipeManagementService);
|
|
|
|
|
private readonly translate = inject(TranslateService);
|
|
|
|
|
|
|
|
|
|
@Input({ required: true }) recipe!: RecipeDetail;
|
|
|
|
|
@Output() recipeUpdated = new EventEmitter<RecipeDetail>();
|
|
|
|
|
|
|
|
|
|
readonly preview = signal<RecalculateRecipeCaloriesResult | null>(null);
|
|
|
|
|
readonly pending = signal(false);
|
|
|
|
|
readonly applyPending = signal(false);
|
|
|
|
|
readonly error = signal<string | null>(null);
|
|
|
|
|
targetCalories = '';
|
|
|
|
|
|
|
|
|
|
readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({
|
|
|
|
|
column: 'name',
|
|
|
|
|
direction: 'asc',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
readonly originalAmounts = computed(
|
|
|
|
|
() => new Map(this.recipe.ingredients.map((ing) => [ing.id, ing.amountGrams])),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
readonly displayRecipe = computed(() => {
|
|
|
|
|
const preview = this.preview();
|
|
|
|
|
if (preview?.applied && preview.recipe) return preview.recipe;
|
|
|
|
|
if (preview) {
|
|
|
|
|
return {
|
|
|
|
|
...this.recipe,
|
|
|
|
|
calories: preview.newCalories ?? this.recipe.calories,
|
|
|
|
|
protein: preview.newProtein ?? this.recipe.protein,
|
|
|
|
|
fat: preview.newFat ?? this.recipe.fat,
|
|
|
|
|
carbs: preview.newCarbs ?? this.recipe.carbs,
|
|
|
|
|
ingredients: preview.ingredients,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return this.recipe;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
readonly showingPreview = computed(() => {
|
|
|
|
|
const preview = this.preview();
|
|
|
|
|
return preview != null && !preview.applied;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
readonly macroCards = computed(() => {
|
|
|
|
|
const recipe = this.displayRecipe();
|
|
|
|
|
const highlight = this.showingPreview();
|
|
|
|
|
return [
|
|
|
|
|
{ label: 'recipes.calories', value: recipe.calories, suffix: 'kcal', accent: 'text-orange-500', highlight },
|
|
|
|
|
{ label: 'recipes.protein', value: recipe.protein, suffix: 'g', accent: 'text-brand-600', highlight },
|
|
|
|
|
{ label: 'recipes.fat', value: recipe.fat, suffix: 'g', accent: 'text-amber-500', highlight },
|
|
|
|
|
{ label: 'recipes.carbs', value: recipe.carbs, suffix: 'g', accent: 'text-sky-500', highlight },
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
readonly sortedIngredients = computed(() => {
|
|
|
|
|
const { column, direction } = this.ingredientSort();
|
|
|
|
|
return sortBy(this.recipe.ingredients, direction, (ing) =>
|
|
|
|
|
return sortBy(this.displayRecipe().ingredients, direction, (ing) =>
|
|
|
|
|
column === 'name' ? ing.name : ing.amountGrams,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ngOnChanges(): void {
|
|
|
|
|
if (this.recipe && !this.targetCalories) {
|
|
|
|
|
this.targetCalories = this.recipe.calories != null ? String(this.recipe.calories) : '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formatAmount(amount: number | null, unit: string | null): string {
|
|
|
|
|
if (amount == null && !unit) return '';
|
|
|
|
|
if (amount == null) return unit ?? '';
|
|
|
|
|
@@ -144,6 +225,12 @@ export class RecipeDetailViewComponent {
|
|
|
|
|
return unit ? `${display} ${unit}` : `${display} g`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
amountChanged(id: number, amount: number | null): boolean {
|
|
|
|
|
if (!this.showingPreview()) return false;
|
|
|
|
|
const previous = this.originalAmounts().get(id);
|
|
|
|
|
return previous != null && amount != null && previous !== amount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleIngredientSort(column: IngredientSortColumn): void {
|
|
|
|
|
this.ingredientSort.update((current) => nextSort(current, column));
|
|
|
|
|
}
|
|
|
|
|
@@ -151,4 +238,34 @@ export class RecipeDetailViewComponent {
|
|
|
|
|
ingredientIndicator(column: IngredientSortColumn): string {
|
|
|
|
|
return sortIndicator(this.ingredientSort(), column);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
runRecalculate(apply: boolean): void {
|
|
|
|
|
const target = Number(this.targetCalories);
|
|
|
|
|
if (!Number.isFinite(target) || target < 50) {
|
|
|
|
|
this.error.set(this.translate.instant('recipes.recalculateCaloriesInvalid'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.error.set(null);
|
|
|
|
|
if (apply) this.applyPending.set(true);
|
|
|
|
|
else this.pending.set(true);
|
|
|
|
|
|
|
|
|
|
this.management.recalculateRecipeCalories(this.recipe.id, target, apply).subscribe({
|
|
|
|
|
next: (result) => {
|
|
|
|
|
this.preview.set(result);
|
|
|
|
|
if (result.applied && result.recipe) {
|
|
|
|
|
this.recipe = result.recipe;
|
|
|
|
|
this.recipeUpdated.emit(result.recipe);
|
|
|
|
|
this.preview.set(null);
|
|
|
|
|
}
|
|
|
|
|
this.pending.set(false);
|
|
|
|
|
this.applyPending.set(false);
|
|
|
|
|
},
|
|
|
|
|
error: (err) => {
|
|
|
|
|
this.error.set(extractApiError(err, this.translate.instant('recipes.recalculateCaloriesFailed')));
|
|
|
|
|
this.pending.set(false);
|
|
|
|
|
this.applyPending.set(false);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|