recipe-app/frontend/src/components/recipes/RecipeDetailPage.tsx
2025-10-21 08:03:08 +02:00

156 lines
6.4 KiB
TypeScript

import {useParams} from "react-router-dom"
import type {RecipeModel} from "../../models/RecipeModel"
import {useEffect, useState} from "react"
import {fetchRecipe} from "../../api/points/RecipePoint"
import {getRecipeEditUrl, getRecipeListUrl} from "../../routes"
import ButtonLink from "../basics/ButtonLink"
import {mapRecipeDtoToModel} from "../../mappers/RecipeMapper"
import {NumberStepControl} from "../basics/NumberStepControl.tsx";
import {NumberedListItem} from "../basics/NumberedListItem.tsx";
/**
* Displays the full detail of a single recipe,
* including its ingredients, instructions, and image.
*/
export default function RecipeDetailPage() {
// Extract recipe ID from route params
const {id} = useParams<{ id: string }>()
// the recipe loaded from the backend, don't change this! it's required for scaling
const [recipe, setRecipe] = useState<RecipeModel | null>(null)
// Working copy for re-calculating ingredients
const [recipeWorkingCopy, setRecipeWorkingCopy] = useState<RecipeModel | null>(null)
// load recipe data whenever id changes
useEffect(() => {
const loadRecipe = async () => {
if (id) {
// Fetch recipe data when editing an existing one
console.log("loading recipe with id", id)
const data = await fetchRecipe(id)
if (data.id != id) {
throw new Error("Id mismatch when loading recipes: " + id + " requested and " + data.id + " received!");
}
setRecipe(mapRecipeDtoToModel(data))
}
}
loadRecipe()
}, [id])
// set original recipe data and working copy when recipe changes
useEffect(() => {
setRecipeWorkingCopy(recipe);
}, [recipe])
if (!recipe || !recipeWorkingCopy) {
return <p className="p-6">Recipe not found.</p>
}
/** recalculate ingredients based on the amount of servings */
const recalculateIngredients = (newAmount: number) => {
// Always calculate factor from the *original recipe*, not the working copy
const factor = newAmount / recipe.servings.amount
// Create a new ingredient list with updated amounts
const updatedIngredientGroupList = recipe.ingredientGroupList.map((ingGrp) => ({
...ingGrp,
ingredientList: ingGrp.ingredientList.map((ing) => ({
...ing,
amount: ing.amount * factor,
}))
}))
// Update working copy with new servings + recalculated ingredients
setRecipeWorkingCopy({
...recipeWorkingCopy,
servings: {
...recipeWorkingCopy.servings,
amount: newAmount,
},
ingredientGroupList: updatedIngredientGroupList,
})
}
return (
/*Container spanning entire screen used to center content horizontally */
<div className="app-bg">
{/* Container defining the maximum width of the content */}
<div className="content-container">
{/* Header - remains in position when scrolling */}
<div className="sticky-header">
<h1 className="content-title mb-0">{recipeWorkingCopy.title}</h1>
</div>
{/* Content */}
<div className="p-6 max-w-2xl mx-auto">
{/* Recipe image */}
{recipe.imageUrl && (
<img
src={recipe.imageUrl}
alt={recipe.title}
className="w-full rounded-xl mb-4 border"
/>
)}
{/* Servings */}
<div
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 bg-gray-200 rounded p-3 mb-4">
<p>
Für {recipeWorkingCopy.servings.amount} {recipeWorkingCopy.servings.unit}
</p>
<NumberStepControl
value={recipeWorkingCopy.servings.amount}
onChange={recalculateIngredients}
min={1}
className="justify-end sm:justify-center"
/>
</div>
{/* Ingredients */}
<h2 className="section-heading">Zutaten</h2>
<ul>
{recipeWorkingCopy.ingredientGroupList.map((group, i) => (
<div key={i}>
{/* the title is optional, only print if present */}
{group.title && group.title.trim() !== "" && (
<h3 className="subsection-heading">{group.title}</h3>
)}
<ul className="default-list">
{group.ingredientList.map((ing, j) => (
<li key={j}>
{ing.amount} {ing.unit ?? ""} {ing.name}
</li>
))}
</ul>
</div>
))}
</ul>
{/* Instructions - @todo add reasonable list delegate component*/}
<ol className="space-y-4">
{recipe.instructionStepList.map((step, j) => (
<NumberedListItem key={j} elementNumber={j + 1} text={step.text}/>
))}
</ol>
{/* Action buttons */}
<div className="button-group">
<ButtonLink
to={recipe.id !== undefined ? getRecipeEditUrl(recipe.id) : getRecipeListUrl()} // @todo show error instead
className="basic-button primary-button-bg primary-button-text"
text="Bearbeiten"
/>
<ButtonLink
to={getRecipeListUrl()}
className="basic-button default-button-bg default-button-text"
text="Zurueck"
/>
</div>
</div>
</div>
</div>
)
}