From 38a57076222b7378df99f3b2857a7d79963fa4d4 Mon Sep 17 00:00:00 2001 From: Anika Raemer Date: Wed, 10 Sep 2025 20:04:26 +0200 Subject: [PATCH] renaming, restructuring, adding an api util to the frontend (currently editPage only and a mock backend --- backend/src/mock_data/recipes.ts | 58 ++++++++++++-- backend/src/server.ts | 25 +++--- backend/src/types/ingredientGroup.ts | 18 +++++ backend/src/types/recipe.ts | 22 ++++-- backend/src/types/servings.ts | 12 +++ frontend/src/App.tsx | 13 ++-- frontend/src/api/recipePoint.ts | 39 ++++++++++ frontend/src/components/RecipeEditView.tsx | 76 ------------------- .../IngredientGroupListEditor.tsx | 4 +- .../{ => recipes}/IngredientListEditor.tsx | 2 +- .../RecipeDetailPage.tsx} | 6 +- .../src/components/recipes/RecipeEditPage.tsx | 76 +++++++++++++++++++ .../components/{ => recipes}/RecipeEditor.tsx | 5 +- .../{ => recipes}/RecipeListItem.tsx | 0 .../RecipeListPage.tsx} | 4 +- frontend/src/config/api.ts | 4 + 16 files changed, 247 insertions(+), 117 deletions(-) create mode 100644 backend/src/types/ingredientGroup.ts create mode 100644 backend/src/types/servings.ts create mode 100644 frontend/src/api/recipePoint.ts delete mode 100644 frontend/src/components/RecipeEditView.tsx rename frontend/src/components/{ => recipes}/IngredientGroupListEditor.tsx (94%) rename frontend/src/components/{ => recipes}/IngredientListEditor.tsx (97%) rename frontend/src/components/{RecipeDetailView.tsx => recipes/RecipeDetailPage.tsx} (95%) create mode 100644 frontend/src/components/recipes/RecipeEditPage.tsx rename frontend/src/components/{ => recipes}/RecipeEditor.tsx (96%) rename frontend/src/components/{ => recipes}/RecipeListItem.tsx (100%) rename frontend/src/components/{RecipeListView.tsx => recipes/RecipeListPage.tsx} (84%) create mode 100644 frontend/src/config/api.ts diff --git a/backend/src/mock_data/recipes.ts b/backend/src/mock_data/recipes.ts index 1b68b34..37bbc5f 100644 --- a/backend/src/mock_data/recipes.ts +++ b/backend/src/mock_data/recipes.ts @@ -1,4 +1,4 @@ -import { Recipe } from "../types/recipe" +import type { Recipe } from "../types/recipe" /** * Mock data set with some sample recipes. @@ -8,12 +8,56 @@ export const recipes: Recipe[] = [ { id: "1", title: "Spaghetti Bolognese", - ingredients: [ - { name: "Spaghetti", amount: 200, unit: "g" }, - { name: "Ground Beef", amount: 300, unit: "g" }, - { name: "Tomato Sauce", amount: 400, unit: "ml" } + servings: { amount: 1, unit: "Person"}, + ingredientGroupList: [ + { + ingredientList: [ + { 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.", - photoUrl: "https://source.unsplash.com/400x300/?spaghetti" + //imageUrl: "https://source.unsplash.com/400x300/?spaghetti" +}, +{ + id: "2", + title: "Spaghetti Carbonara", + servings: { amount: 4, unit: "Persons"}, + ingredientGroupList: [ + { + ingredientList:[ + { name: "Spaghetti", amount: 500, unit: "g" }, + { name: "Bacon", amount: 150, unit: "g" }, + { name: "Cream", amount: 200, unit: "ml" }, + { 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.", + //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" } -] +] \ No newline at end of file diff --git a/backend/src/server.ts b/backend/src/server.ts index bfc8f45..9208641 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,37 +2,38 @@ import express from "express"; import cors from "cors"; import { v4 as uuidv4 } from "uuid"; -import { recipes } from "../mock_data/recipes" -import { Recipe } from "../types/recipe" +import { recipes } from "./mock_data/recipes" +import { Recipe } from "./types/recipe" const app = express(); app.use(cors()); app.use(express.json()); +let recipeList = recipes; // Routes -app.get("/recipes", (req, res) => res.json(recipes)); +app.get("/recipe", (req, res) => res.json(recipeList)); -app.get("/recipes/:id", (req, res) => { - const recipe = recipes.find(r => r.id === req.params.id); +app.get("/recipe/:id", (req, res) => { + const recipe = recipeList.find(r => r.id === req.params.id); recipe ? res.json(recipe) : res.status(404).send("Recipe not found"); }); -app.post("/recipes", (req, res) => { +app.post("/recipe", (req, res) => { const newRecipe: Recipe = { id: uuidv4(), ...req.body }; - recipes.push(newRecipe); + recipeList.push(newRecipe); res.status(201).json(newRecipe); }); -app.put("/recipes/:id", (req, res) => { +app.put("/recipe/:id", (req, res) => { const index = recipes.findIndex(r => r.id === req.params.id); if (index === -1) return res.status(404).send("Recipe not found"); - recipes[index] = { ...recipes[index], ...req.body }; - res.json(recipes[index]); + recipeList[index] = { ...recipeList[index], ...req.body }; + res.json(recipeList[index]); }); -app.delete("/recipes/:id", (req, res) => { - recipes = recipes.filter(r => r.id !== req.params.id); +app.delete("/recipe/:id", (req, res) => { + recipeList = recipeList.filter(r => r.id !== req.params.id); res.status(204).send(); }); diff --git a/backend/src/types/ingredientGroup.ts b/backend/src/types/ingredientGroup.ts new file mode 100644 index 0000000..b5c89cd --- /dev/null +++ b/backend/src/types/ingredientGroup.ts @@ -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[] +} \ No newline at end of file diff --git a/backend/src/types/recipe.ts b/backend/src/types/recipe.ts index 7be4a66..2ab3780 100644 --- a/backend/src/types/recipe.ts +++ b/backend/src/types/recipe.ts @@ -1,8 +1,16 @@ -import { Ingredient } from "./ingredient" +import type { IngredientGroup } from "./ingredientGroup" +import type { Servings } from "./servings" /** * Represents a recipe object in the application. */ +/* + * @todo ingredient groups! There may be serveral ingredient lists, each with a title. + * e.g. for the dough, for the filling, for the icing,... + * - add type ingredient group with an optional title and a list of ingredients + * - adapt RecipeDetailView + * - add an IngredientGroupListEditor for handling IngredientGroups +*/ export interface Recipe { /** Unique identifier for the recipe */ id: string @@ -10,13 +18,17 @@ export interface Recipe { /** Title of the recipe */ title: string - /** List of ingredients with amount + unit */ - ingredients: Ingredient[] + /** List of ingredients groups containing the ingredients of the recipe */ + ingredientGroupList: IngredientGroup[] /** Preparation instructions */ instructions: string + /** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */ + servings: Servings + + /** Unit for the quantity */ + /** Optional image URL for the recipe */ imageUrl?: string -} - +} \ No newline at end of file diff --git a/backend/src/types/servings.ts b/backend/src/types/servings.ts new file mode 100644 index 0000000..be3808f --- /dev/null +++ b/backend/src/types/servings.ts @@ -0,0 +1,12 @@ +/** + * Defines how many servings of the dish are prepared when following the recipe + */ + +export interface Servings{ + /** Amount of servings */ + amount: number, + + /** Unit, e.g. 4 persons, 2 glasses, 12 cupcakes */ + unit: string + +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d655c62..e52a9f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom" -import RecipeListView from "./components/RecipeListView" -import RecipeDetailView from "./components/RecipeDetailView" -import RecipeEditView from "./components/RecipeEditView" +import RecipeDetailPage from "./components/recipes/RecipeDetailPage" +import RecipeEditPage from "./components/recipes/RecipeEditPage" +import RecipeListPage from "./components/recipes/RecipeListPage" + import "./App.css" /** @@ -13,13 +14,13 @@ function App() { {/* Home page: list of recipes */} - } /> + } /> {/* Detail page: shows one recipe */} - } /> + } /> {/* Edit page: form to edit a recipe */} - } /> + } /> ) diff --git a/frontend/src/api/recipePoint.ts b/frontend/src/api/recipePoint.ts new file mode 100644 index 0000000..f2916a7 --- /dev/null +++ b/frontend/src/api/recipePoint.ts @@ -0,0 +1,39 @@ +import type { Recipe } from "../types/recipe" +import { API_BASE_URL } from "../config/api" + +/** + * Util for handling the recipe api + */ +const RECIPE_URL = `${API_BASE_URL}/recipe` + +export async function fetchRecipe(id: string): Promise { + const res = await fetch(`${RECIPE_URL}/${id}`) + if (!res.ok) { + throw new Error(`Failed to fetch recipe with id ${id}`) + } + return res.json() +} + +export async function createRecipe(recipe: Recipe): Promise { + const res = await fetch(RECIPE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(recipe), + }) + if (!res.ok) { + throw new Error("Failed to create recipe") + } + return res.json() +} + +export async function updateRecipe(recipe: Recipe): Promise { + const res = await fetch(`${RECIPE_URL}/${recipe.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(recipe), + }) + if (!res.ok) { + throw new Error(`Failed to update recipe with id ${recipe.id}`) + } + return res.json() +} diff --git a/frontend/src/components/RecipeEditView.tsx b/frontend/src/components/RecipeEditView.tsx deleted file mode 100644 index d6bc00e..0000000 --- a/frontend/src/components/RecipeEditView.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useParams, useNavigate } from "react-router-dom" -//import { useEffect, useState } from "react" -import type { Recipe } from "../types/recipe" -import RecipeEditor from "./RecipeEditor" -import { recipes } from "../mock_data/recipes" - - -export default function RecipeEditView() { - const { id } = useParams<{ id: string }>() - const navigate = useNavigate() - //const [recipe, setRecipe] = useState(null) - console.log("searching for recipe with id", id) - const recipe = recipes.find((r) => r.id === id) - - // Fetch recipe data when editing an existing one - /*seEffect(() => { - if (id) { - fetch(`http://localhost:4000/recipes/${id}`) - .then(res => res.json()) - .then(data => setRecipe(data)) - } else { - // new recipe case - setRecipe({ - id: "", - title: "", - ingredients: [], - instructions: "", - photoUrl: "", - }) - } - }, [id]) - - const handleSave = async (updated: Recipe) => { - if (updated.id) { - await fetch(`http://localhost:4000/recipes/${updated.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updated), - }) - } else { - await fetch("http://localhost:4000/recipes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updated), - }) - } - navigate("/") // back to list - } - if (!recipe) return
Loading...
- */ - const handleSave = (updatedRecipe : Recipe) => { - console.log("Saving", updatedRecipe.title) - navigateBack(); - } - - const handleCancel = () => { - console.log("Cancelling edit mode for", recipe ? recipe.id : "no recipe at all") - navigateBack(); - } - - const navigateBack = () => { - if(recipe && recipe.id){ - console.log("navigating to recipe with id", recipe.id) - navigate("/recipe/" + recipe.id) // go back to detail view - } else { - console.log("navigating back to list as no recipe was selected") - navigate("/") // no recipe -> go back to list - } - } - // error handling -> if there is no recipe, we cannot open edit view - if (!recipe) return
Oops, there's no recipe in RecipeEditView
- console.log("opening recipe in edit mode", recipe.title, id) - - - return -} \ No newline at end of file diff --git a/frontend/src/components/IngredientGroupListEditor.tsx b/frontend/src/components/recipes/IngredientGroupListEditor.tsx similarity index 94% rename from frontend/src/components/IngredientGroupListEditor.tsx rename to frontend/src/components/recipes/IngredientGroupListEditor.tsx index bf0da33..2fbed6b 100644 --- a/frontend/src/components/IngredientGroupListEditor.tsx +++ b/frontend/src/components/recipes/IngredientGroupListEditor.tsx @@ -2,8 +2,8 @@ * Editor for ingredient groups */ -import type { Ingredient } from "../types/ingredient" -import type { IngredientGroup } from "../types/ingredientGroup" +import type { Ingredient } from "../../types/ingredient" +import type { IngredientGroup } from "../../types/ingredientGroup" import { IngredientListEditor } from "./IngredientListEditor" type IngredientGroupListEditorProps = { diff --git a/frontend/src/components/IngredientListEditor.tsx b/frontend/src/components/recipes/IngredientListEditor.tsx similarity index 97% rename from frontend/src/components/IngredientListEditor.tsx rename to frontend/src/components/recipes/IngredientListEditor.tsx index 55f767e..7c24ce5 100644 --- a/frontend/src/components/IngredientListEditor.tsx +++ b/frontend/src/components/recipes/IngredientListEditor.tsx @@ -1,4 +1,4 @@ -import type { Ingredient } from "../types/ingredient" +import type { Ingredient } from "../../types/ingredient" /** * Editor for handling the ingredient list diff --git a/frontend/src/components/RecipeDetailView.tsx b/frontend/src/components/recipes/RecipeDetailPage.tsx similarity index 95% rename from frontend/src/components/RecipeDetailView.tsx rename to frontend/src/components/recipes/RecipeDetailPage.tsx index 8785dc8..6fac82b 100644 --- a/frontend/src/components/RecipeDetailView.tsx +++ b/frontend/src/components/recipes/RecipeDetailPage.tsx @@ -1,6 +1,6 @@ import { useParams, Link } from "react-router-dom" -import { recipes } from "../mock_data/recipes" -import type { Recipe } from "../types/recipe" +import { recipes } from "../../mock_data/recipes" +import type { Recipe } from "../../types/recipe" import { useState } from "react" @@ -8,7 +8,7 @@ import { useState } from "react" * Displays the full detail of a single recipe, * including its ingredients, instructions, and image. */ -export default function RecipeDetailView() { +export default function RecipeDetailPage() { // Extract recipe ID from route params const { id } = useParams<{ id: string }>() const recipe = recipes.find((r) => r.id === id) diff --git a/frontend/src/components/recipes/RecipeEditPage.tsx b/frontend/src/components/recipes/RecipeEditPage.tsx new file mode 100644 index 0000000..73ba44e --- /dev/null +++ b/frontend/src/components/recipes/RecipeEditPage.tsx @@ -0,0 +1,76 @@ +import { useParams, useNavigate } from "react-router-dom" +import { useEffect, useState } from "react" +import type { Recipe } from "../../types/recipe" +import RecipeEditor from "./RecipeEditor" +import { fetchRecipe, createRecipe, updateRecipe } from "../../api/recipePoint" + +export default function RecipeEditPage() { + const { id } = useParams<{ id: string }>() + const [recipe, setRecipe] = useState(null) + const navigate = useNavigate() + + useEffect(() => { + const loadRecipe = async () => { + if (id) { + try { + // Fetch recipe data when editing an existing one + const data = await fetchRecipe(id) + setRecipe(data) + } catch (err) { + console.error(err) + } + } else { + // New recipe case + setRecipe({ + id: "", + title: "", + ingredientGroupList: [ + ], + instructions: "", + servings: { + amount: 1, + unit: "" + } + }) + } + } + + loadRecipe() + }, [id]) + + const handleSave = async (updated: Recipe) => { + try { + if (updated.id) { + await updateRecipe(updated) + } else { + await createRecipe(updated) + } + navigateBack(); + } catch (err) { + console.error(err) + } + } + + + + const handleCancel = () => { + console.log("Cancelling edit mode for", recipe ? recipe.id : "no recipe at all") + navigateBack(); + } + + const navigateBack = () => { + if(recipe && recipe.id){ + console.log("navigating to recipe with id", recipe.id) + navigate("/recipe/" + recipe.id) // go back to detail view + } else { + console.log("navigating back to list as no recipe was selected") + navigate("/") // no recipe -> go back to list + } + } + // error handling -> if there is no recipe, we cannot open edit view + if (!recipe) { + return
Loading...
+ } + + return +} \ No newline at end of file diff --git a/frontend/src/components/RecipeEditor.tsx b/frontend/src/components/recipes/RecipeEditor.tsx similarity index 96% rename from frontend/src/components/RecipeEditor.tsx rename to frontend/src/components/recipes/RecipeEditor.tsx index 18b0d4c..ea8540d 100644 --- a/frontend/src/components/RecipeEditor.tsx +++ b/frontend/src/components/recipes/RecipeEditor.tsx @@ -1,8 +1,7 @@ import { useState } from "react" -import type { Recipe } from "../types/recipe" -import type { IngredientGroup } from "../types/ingredientGroup" +import type { Recipe } from "../../types/recipe" +import type { IngredientGroup } from "../../types/ingredientGroup" import { IngredientGroupListEditor } from "./IngredientGroupListEditor" -import { IngredientListEditor } from "./IngredientListEditor" type RecipeEditorProps = { recipe: Recipe diff --git a/frontend/src/components/RecipeListItem.tsx b/frontend/src/components/recipes/RecipeListItem.tsx similarity index 100% rename from frontend/src/components/RecipeListItem.tsx rename to frontend/src/components/recipes/RecipeListItem.tsx diff --git a/frontend/src/components/RecipeListView.tsx b/frontend/src/components/recipes/RecipeListPage.tsx similarity index 84% rename from frontend/src/components/RecipeListView.tsx rename to frontend/src/components/recipes/RecipeListPage.tsx index 6aed0a8..3ea4c35 100644 --- a/frontend/src/components/RecipeListView.tsx +++ b/frontend/src/components/recipes/RecipeListPage.tsx @@ -1,11 +1,11 @@ -import { recipes } from "../mock_data/recipes" +import { recipes } from "../../mock_data/recipes" import RecipeListItem from "./RecipeListItem" /** * Displays a list of recipes in a sidebar layout. * Each recipe link fills the available width. */ -export default function RecipeListView() { +export default function RecipeListPage() { return (

Recipes

diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts new file mode 100644 index 0000000..ccbd753 --- /dev/null +++ b/frontend/src/config/api.ts @@ -0,0 +1,4 @@ +/** + * Backend URL + */ +export const API_BASE_URL = "http://localhost:4000" \ No newline at end of file