initial commit

This commit is contained in:
Anika Raemer 2025-09-06 10:56:40 +02:00
commit ee8aedd857
1599 changed files with 652440 additions and 0 deletions

10
frontend/src/App.css Normal file
View file

@ -0,0 +1,10 @@
/* Import Tailwind layers */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* App-specific tweaks */
.app-container {
@apply p-4;
}

27
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,27 @@
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"
/**
* Main application component.
* Defines routes for the recipe list, detail view, and edit form.
*/
function App() {
return (
<Router>
<Routes>
{/* Home page: list of recipes */}
<Route path="/" element={<RecipeListView />} />
{/* Detail page: shows one recipe */}
<Route path="/recipe/:id" element={<RecipeDetailView />} />
{/* Edit page: form to edit a recipe */}
<Route path="/recipe/:id/edit" element={<RecipeEditView />} />
</Routes>
</Router>
)
}
export default App

View file

@ -0,0 +1,67 @@
import { useState } from "react"
import type { Ingredient } from "../types/ingredient"
type IngredientListEditorProps = {
ingredients: Ingredient[]
onChange: (ingredients: Ingredient[]) => void
}
export function IngredientListEditor({ ingredients, onChange }: IngredientListEditorProps) {
const handleUpdate = (index: number, field: keyof Ingredient, value: string | number) => {
const updated = ingredients.map((ing, i) =>
i === index ? { ...ing, [field]: field === "amount" ? Number(value) : value } : ing
)
onChange(updated)
}
const handleAdd = () => {
onChange([...ingredients, { name: "", amount: 0, unit: "" }])
}
const handleRemove = (index: number) => {
onChange(ingredients.filter((_, i) => i !== index))
}
return (
<div>
<h3 className="font-semibold mb-2">Ingredients</h3>
{ingredients.map((ing, index) => (
<div key={index} className="flex gap-2 mb-2 items-center">
<input
type="number"
className="border p-2 w-20"
placeholder="Amount"
value={ing.amount}
onChange={e => handleUpdate(index, "amount", e.target.value)}
/>
<input
className="border p-2 w-20"
placeholder="Unit"
value={ing.unit ?? ""}
onChange={e => handleUpdate(index, "unit", e.target.value)}
/>
<input
className="border p-2 flex-1"
placeholder="Name"
value={ing.name}
onChange={e => handleUpdate(index, "name", e.target.value)}
/>
<button
type="button"
className="p-2 bg-red-500 text-white rounded"
onClick={() => handleRemove(index)}
>
</button>
</div>
))}
<button
type="button"
className="p-2 bg-green-500 text-white rounded"
onClick={handleAdd}
>
Add Ingredient
</button>
</div>
)
}

View file

@ -0,0 +1,61 @@
import { useParams, Link } from "react-router-dom"
import { recipes } from "../mock_data/recipes"
/**
* Displays the full detail of a single recipe,
* including its ingredients, instructions, and image.
*/
export default function RecipeDetailView() {
// Extract recipe ID from route params
const { id } = useParams<{ id: string }>()
const recipe = recipes.find((r) => r.id === id)
if (!recipe) {
return <p className="p-6">Recipe not found.</p>
}
return (
<div className="p-6 max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{recipe.title}</h1>
{/* Recipe image */}
{recipe.imageUrl && (
<img
src={recipe.imageUrl}
alt={recipe.title}
className="w-full rounded-xl mb-4"
/>
)}
{/* Ingredients */}
<h2 className="font-semibold">Ingredients</h2>
<ul className="list-disc pl-6">
{recipe.ingredients.map((ing, i) => (
<li key={i}>
{ing.amount} {ing.unit ?? ""} {ing.name}
</li>
))}
</ul>
{/* Instructions */}
<h2 className="text-xl font-semibold mb-2">Instructions</h2>
<p className="mb-6">{recipe.instructions}</p>
{/* Action buttons */}
<div className="flex gap-4">
<Link
to={`/recipe/${recipe.id}/edit`}
className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600"
>
Edit
</Link>
<Link
to="/"
className="bg-gray-300 px-4 py-2 rounded-lg hover:bg-gray-400"
>
Back
</Link>
</div>
</div>
)
}

View file

@ -0,0 +1,60 @@
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<Recipe | null>(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 <div>Loading...</div>
*/
const handleSave = () => {
console.log("Saving")
navigate("/") // back to list
}
if (!recipe) return <div>Oops, there's no recipe in RecipeEditView</div>
console.log("opening recipe in edit mode", recipe.title, id)
return <RecipeEditor recipe={recipe} onSave={handleSave} />
}

View file

@ -0,0 +1,61 @@
import { useState } from "react"
import type { Recipe } from "../types/recipe"
import type { Ingredient } from "../types/ingredient"
import {IngredientListEditor} from "./IngredientListEditor"
type RecipeEditorProps = {
recipe: Recipe
onSave: (recipe: Recipe) => void
}
/**
* Editor component for managing a recipe, including title,
* ingredients (with amount, unit, name), instructions, and image URL.
*/
export default function RecipeEditor({ recipe, onSave }: RecipeEditorProps) {
const [draft, setDraft] = useState<Recipe>(recipe)
const updateIngredients = (ingredients: Ingredient[]) => {
setDraft({ ...draft, ingredients })
}
if (!recipe) return <div>Oops, there's no recipe in RecipeEditor...</div>
return (
<div>
<h2 className="text-xl font-bold mb-2">
{recipe.id ? "Edit Recipe" : "New Recipe"}
</h2>
{/* Title */}
<h3>Title</h3>
<input
className="border p-2 w-full mb-2"
placeholder="Title"
value={draft.title}
onChange={e => setDraft({ ...draft, title: e.target.value })}
/>
{/* Ingredient List */}
<IngredientListEditor
ingredients={draft.ingredients}
onChange={updateIngredients}
/>
<h3>Instructions</h3>
{/* Instructions */}
<textarea
className="border p-2 w-full mb-2"
placeholder="Instructions"
value={draft.instructions}
onChange={e => setDraft({ ...draft, instructions: e.target.value })}
/>
{/* Save Button */}
<button
className="p-2 bg-green-500 text-white rounded"
onClick={() => onSave(draft)}
>
💾 Save
</button>
</div>
)
}

View file

@ -0,0 +1,39 @@
import { Link } from "react-router-dom"
import { recipes } from "../mock_data/recipes"
/**
* Displays a list of recipes in a grid layout.
* Each recipe links to its detail view.
*/
export default function RecipeListView() {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">Recipes</h1>
{/* Grid of recipe cards */}
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3">
{recipes.map((recipe) => (
<Link
key={recipe.id}
to={`/recipe/${recipe.id}`}
className="block bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition"
>
{/* Thumbnail image */}
{recipe.imageUrl && (
<img
src={recipe.imageUrl}
alt={recipe.title}
className="w-full h-40 object-cover"
/>
)}
{/* Recipe title */}
<div className="p-4">
<h2 className="text-xl font-semibold">{recipe.title}</h2>
</div>
</Link>
))}
</div>
</div>
)
}

72
frontend/src/index.css Normal file
View file

@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,19 @@
import type { Recipe } from "../types/recipe"
/**
* Mock data set with some sample recipes.
* In a real application, this would be fetched from a backend.
*/
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" }
],
instructions: "Cook pasta. Prepare sauce. Mix together. Serve hot.",
photoUrl: "https://source.unsplash.com/400x300/?spaghetti"
}
]

View file

@ -0,0 +1,15 @@
/**
* Represents a single ingredient in a recipe.
*/
export interface Ingredient {
/** Name of the ingredient (e.g. "Spaghetti") */
name: string
/** Quantity required (e.g. 200, 1.5) */
amount: number
/** Unit of measurement (e.g. "g", "tbsp", "cups").
* Optional for cases like "1 egg".
*/
unit?: string
}

View file

@ -0,0 +1,22 @@
import type { Ingredient } from "./ingredient"
/**
* Represents a recipe object in the application.
*/
export interface Recipe {
/** Unique identifier for the recipe */
id: string
/** Title of the recipe */
title: string
/** List of ingredients with amount + unit */
ingredients: Ingredient[]
/** Preparation instructions */
instructions: string
/** Optional image URL for the recipe */
imageUrl?: string
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />