added ingredient group editor
This commit is contained in:
parent
37057f19f1
commit
d4e8a4d09a
7 changed files with 154 additions and 32 deletions
67
frontend/src/components/IngredientGroupListEditor.tsx
Normal file
67
frontend/src/components/IngredientGroupListEditor.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Editor for ingredient groups
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Ingredient } from "../types/ingredient"
|
||||||
|
import type { IngredientGroup } from "../types/ingredientGroup"
|
||||||
|
import { IngredientListEditor } from "./IngredientListEditor"
|
||||||
|
|
||||||
|
type IngredientGroupListEditorProps = {
|
||||||
|
ingredientGroupList: IngredientGroup[]
|
||||||
|
onChange: (ingredientGroupList: IngredientGroup[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngredientGroupListEditor({ ingredientGroupList, onChange }: IngredientGroupListEditorProps) {
|
||||||
|
const handleUpdate = (index: number, field: keyof IngredientGroup, value: string|Ingredient[] ) => {
|
||||||
|
const updated = ingredientGroupList.map((ingGrp, i) =>
|
||||||
|
i === index ? { ...ingGrp, [field]: value} : ingGrp
|
||||||
|
)
|
||||||
|
onChange(updated)
|
||||||
|
}
|
||||||
|
const updateIngredientList = (index: number, ingredientList: Ingredient[]) => {
|
||||||
|
handleUpdate(index, "ingredientList", ingredientList)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...ingredientGroupList, { title: "", ingredientList: [] }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
onChange(ingredientGroupList.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="subsection-heading">Ingredient Groups</h3>
|
||||||
|
{ingredientGroupList.map((ingGrp, index) => (
|
||||||
|
<div key={index} className="mb-2 py-4 border-y border-gray-300">
|
||||||
|
<div className="flex columns-2 gap-2 mb-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Group title (Optional)"
|
||||||
|
value = {ingGrp.title}
|
||||||
|
onChange={ e => handleUpdate(index, "title", e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dark-button whitespace-nowrap"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
>
|
||||||
|
Remove Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<IngredientListEditor
|
||||||
|
ingredients={ingGrp.ingredientList}
|
||||||
|
onChange={list => updateIngredientList(index,list)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button mt-2"
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
➕ Add Ingredient Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,6 @@ export function IngredientListEditor({ ingredients, onChange }: IngredientListEd
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="subsection-heading">Ingredients</h3>
|
|
||||||
{ingredients.map((ing, index) => (
|
{ingredients.map((ing, index) => (
|
||||||
<div key={index} className="flex gap-2 mb-2 items-center">
|
<div key={index} className="flex gap-2 mb-2 items-center">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ export default function RecipeDetailView() {
|
||||||
<h1 className="content-title">{recipeWorkingCopy.title}</h1>
|
<h1 className="content-title">{recipeWorkingCopy.title}</h1>
|
||||||
|
|
||||||
{/* Recipe image */}
|
{/* Recipe image */}
|
||||||
{recipeWorkingCopy.imageUrl && (
|
{recipe.imageUrl && (
|
||||||
<img
|
<img
|
||||||
src={recipeWorkingCopy.imageUrl}
|
src={recipe.imageUrl}
|
||||||
alt={recipeWorkingCopy.title}
|
alt={recipe.title}
|
||||||
className="w-full rounded-xl mb-4 border"
|
className="w-full rounded-xl mb-4 border"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -71,17 +71,26 @@ export default function RecipeDetailView() {
|
||||||
</div>
|
</div>
|
||||||
{/* Ingredients */}
|
{/* Ingredients */}
|
||||||
<h2 className="section-heading">Zutaten</h2>
|
<h2 className="section-heading">Zutaten</h2>
|
||||||
<ul className="default-list">
|
<ul>
|
||||||
{recipeWorkingCopy.ingredients.map((ing, i) => (
|
{recipe.ingredientGroupList.map((group,i) => (
|
||||||
<li key={i}>
|
<div key={i}>
|
||||||
{ing.amount} {ing.unit ?? ""} {ing.name}
|
{group.title && group.title.trim() !== "" && (
|
||||||
</li>
|
<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>
|
</ul>
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<h2 className="section-heading">Zubereitung</h2>
|
<h2 className="section-heading">Zubereitung</h2>
|
||||||
<p className="mb-6">{recipeWorkingCopy.instructions}</p>
|
<p className="mb-6">{recipe.instructions}</p>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import type { Recipe } from "../types/recipe"
|
import type { Recipe } from "../types/recipe"
|
||||||
import type { Ingredient } from "../types/ingredient"
|
import type { IngredientGroup } from "../types/ingredientGroup"
|
||||||
import {IngredientListEditor} from "./IngredientListEditor"
|
import { IngredientGroupListEditor } from "./IngredientGroupListEditor"
|
||||||
|
|
||||||
type RecipeEditorProps = {
|
type RecipeEditorProps = {
|
||||||
recipe: Recipe
|
recipe: Recipe
|
||||||
|
|
@ -12,6 +12,7 @@ type RecipeEditorProps = {
|
||||||
/**
|
/**
|
||||||
* Editor component for managing a recipe, including title,
|
* Editor component for managing a recipe, including title,
|
||||||
* ingredients (with amount, unit, name), instructions, and image URL.
|
* ingredients (with amount, unit, name), instructions, and image URL.
|
||||||
|
* @todo adapt to ingredientGroups!
|
||||||
*/
|
*/
|
||||||
export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorProps) {
|
export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorProps) {
|
||||||
/** draft of the new recipe */
|
/** draft of the new recipe */
|
||||||
|
|
@ -23,8 +24,8 @@ export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorP
|
||||||
* Update ingredients
|
* Update ingredients
|
||||||
* @param ingredients new ingredients
|
* @param ingredients new ingredients
|
||||||
*/
|
*/
|
||||||
const updateIngredients = (ingredients: Ingredient[]) => {
|
const updateIngredientGroupList = (ingredientGroupList: IngredientGroup[]) => {
|
||||||
setDraft({ ...draft, ingredients })
|
setDraft({ ...draft, ingredientGroupList })
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Validate recipe
|
* Validate recipe
|
||||||
|
|
@ -40,7 +41,7 @@ export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorP
|
||||||
|
|
||||||
// the incredient list must not be empty
|
// the incredient list must not be empty
|
||||||
// @todo enhance validation and visualization of ingredient errors
|
// @todo enhance validation and visualization of ingredient errors
|
||||||
if (!draft.ingredients || draft.ingredients.length === 0) {
|
if (!draft.ingredientGroupList || draft.ingredientGroupList.length === 0) {
|
||||||
newErrors.ingredients = true
|
newErrors.ingredients = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,9 +100,9 @@ export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorP
|
||||||
</div>
|
</div>
|
||||||
{/* Ingredient List - @todo better visualization of errors! */}
|
{/* Ingredient List - @todo better visualization of errors! */}
|
||||||
<div className={errors.ingredients ? "border border-red-500 rounded p-2" : ""}>
|
<div className={errors.ingredients ? "border border-red-500 rounded p-2" : ""}>
|
||||||
<IngredientListEditor
|
<IngredientGroupListEditor
|
||||||
ingredients={draft.ingredients}
|
ingredientGroupList={draft.ingredientGroupList}
|
||||||
onChange={updateIngredients}
|
onChange={updateIngredientGroupList}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,14 @@ export const recipes: Recipe[] = [
|
||||||
id: "1",
|
id: "1",
|
||||||
title: "Spaghetti Bolognese",
|
title: "Spaghetti Bolognese",
|
||||||
servings: { amount: 1, unit: "Person"},
|
servings: { amount: 1, unit: "Person"},
|
||||||
ingredients: [
|
ingredientGroupList: [
|
||||||
{ name: "Spaghetti", amount: 200, unit: "g" },
|
{
|
||||||
{ name: "Ground Beef", amount: 300, unit: "g" },
|
ingredientList: [
|
||||||
{ name: "Tomato Sauce", amount: 400, unit: "ml" }
|
{ name: "Spaghetti", amount: 200, unit: "g" },
|
||||||
|
{ name: "Ground Beef", amount: 300, unit: "g" },
|
||||||
|
{ name: "Tomato Sauce", amount: 400, unit: "ml" }
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
instructions: "Cook pasta. Prepare sauce. Mix together. Serve hot.",
|
instructions: "Cook pasta. Prepare sauce. Mix together. Serve hot.",
|
||||||
//imageUrl: "https://source.unsplash.com/400x300/?spaghetti"
|
//imageUrl: "https://source.unsplash.com/400x300/?spaghetti"
|
||||||
|
|
@ -21,15 +25,39 @@ export const recipes: Recipe[] = [
|
||||||
id: "2",
|
id: "2",
|
||||||
title: "Spaghetti Carbonara",
|
title: "Spaghetti Carbonara",
|
||||||
servings: { amount: 4, unit: "Persons"},
|
servings: { amount: 4, unit: "Persons"},
|
||||||
ingredients: [
|
ingredientGroupList: [
|
||||||
{ name: "Spaghetti", amount: 500, unit: "g" },
|
{
|
||||||
{ name: "Bacon", amount: 150, unit: "g" },
|
ingredientList:[
|
||||||
{ name: "Cream", amount: 200, unit: "ml" },
|
{ name: "Spaghetti", amount: 500, unit: "g" },
|
||||||
{ name: "Onion", amount: 1},
|
{ name: "Bacon", amount: 150, unit: "g" },
|
||||||
{ name: "Parmesan cheese", amount: 200, unit: "g"},
|
{ name: "Cream", amount: 200, unit: "ml" },
|
||||||
{ name: "Olives", amount: 100, unit: "g"}
|
{ name: "Onion", amount: 1},
|
||||||
|
{ name: "Parmesan cheese", amount: 200, unit: "g"},
|
||||||
|
{ name: "Olives", amount: 100, unit: "g"}
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
instructions: "Cook pasta. Prepare sauce. Mix together. Serve hot.",
|
instructions: "Cook pasta. Prepare sauce. Mix together. Serve hot.",
|
||||||
//imageUrl: "https://source.unsplash.com/400x300/?spaghetti"
|
//imageUrl: "https://source.unsplash.com/400x300/?spaghetti"
|
||||||
},
|
},
|
||||||
|
{ id: "3",
|
||||||
|
title: "Apfelkuchen Edeltrud",
|
||||||
|
servings: { amount: 1, unit: "Kuchen"},
|
||||||
|
ingredientGroupList:[
|
||||||
|
{
|
||||||
|
title: "Fuer den Teig",
|
||||||
|
ingredientList: [
|
||||||
|
{ name: "Mehl", amount: 400, unit: "g" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fuer die Fuellung",
|
||||||
|
ingredientList:[
|
||||||
|
{name: "Aepfel", amount: 4},
|
||||||
|
{name: "Rosinen", amount: 1, unit: "Hand voll"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
instructions: "Einen Muerbteig von 400 g Mehl zubereiten"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
18
frontend/src/types/ingredientGroup.ts
Normal file
18
frontend/src/types/ingredientGroup.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { Ingredient } from "./ingredient"
|
||||||
|
/**
|
||||||
|
* A group of ingredients
|
||||||
|
* Consisting of title and ingredient list, this interface is used to group
|
||||||
|
* the ingredients for a specific part of the dish, e.g., dough, filling and
|
||||||
|
* icing of a cake
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IngredientGroup {
|
||||||
|
/**
|
||||||
|
* Title of the group describing its purpose
|
||||||
|
* The title is optional as recipes consisting of a single ingredient group usually don't
|
||||||
|
* supply a title
|
||||||
|
*/
|
||||||
|
title? : string
|
||||||
|
/** Ingredients */
|
||||||
|
ingredientList : Ingredient[]
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Ingredient } from "./ingredient"
|
import type { IngredientGroup } from "./ingredientGroup"
|
||||||
import type { Servings } from "./servings"
|
import type { Servings } from "./servings"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,8 +18,8 @@ export interface Recipe {
|
||||||
/** Title of the recipe */
|
/** Title of the recipe */
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
/** List of ingredients with amount + unit */
|
/** List of ingredients groups containing the ingredients of the recipe */
|
||||||
ingredients: Ingredient[]
|
ingredientGroupList: IngredientGroup[]
|
||||||
|
|
||||||
/** Preparation instructions */
|
/** Preparation instructions */
|
||||||
instructions: string
|
instructions: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue