160 lines
6.6 KiB
TypeScript
160 lines
6.6 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";
|
|
import {ButtonType} from "../basics/BasicButtonDefinitions.ts";
|
|
import StickyHeader from "../basics/StickyHeader.tsx";
|
|
import ButtonGroupLayout from "../basics/ButtonGroupLayout.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,
|
|
// ensure only to recalculate the amount of ingredients that actually have an amout...
|
|
amount: (ing.amount) ? ing.amount * factor : undefined,
|
|
}))
|
|
}))
|
|
|
|
// 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-bg">
|
|
{/* Header - remains in position when scrolling */}
|
|
<StickyHeader>
|
|
<h1>{recipeWorkingCopy.title}</h1>
|
|
</StickyHeader>
|
|
|
|
{/* Content */}
|
|
<div className="content-container">
|
|
{/* 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 highlight-container-bg 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>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="highlight-container-bg mb-2">{group.title}</h3>
|
|
)}
|
|
<ul>
|
|
{group.ingredientList.map((ing, j) => (
|
|
<li key={j} className="border-b border-gray-300 last:border-b-0 p-2">
|
|
{ing.amount ?? ""} {ing.unit ?? ""} {ing.name}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</ul>
|
|
|
|
{/* Instructions */}
|
|
<h2>Zubereitung</h2>
|
|
<ol className="space-y-4">
|
|
{recipe.instructionStepList.map((step, j) => (
|
|
<NumberedListItem key={j} index={j} text={step.text}/>
|
|
))}
|
|
</ol>
|
|
|
|
{/* Action buttons */}
|
|
<ButtonGroupLayout>
|
|
<ButtonLink
|
|
to={recipe.id !== undefined ? getRecipeEditUrl(recipe.id) : getRecipeListUrl()} // @todo show error instead
|
|
buttonType={ButtonType.PrimaryButton}
|
|
text="Bearbeiten"
|
|
/>
|
|
<ButtonLink
|
|
to={getRecipeListUrl()}
|
|
text="Zurueck"
|
|
/>
|
|
</ButtonGroupLayout>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|