initial commit
This commit is contained in:
commit
ee8aedd857
1599 changed files with 652440 additions and 0 deletions
67
frontend/src/components/IngredientListEditor.tsx
Normal file
67
frontend/src/components/IngredientListEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
frontend/src/components/RecipeDetailView.tsx
Normal file
61
frontend/src/components/RecipeDetailView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
frontend/src/components/RecipeEditView.tsx
Normal file
60
frontend/src/components/RecipeEditView.tsx
Normal 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} />
|
||||
}
|
||||
61
frontend/src/components/RecipeEditor.tsx
Normal file
61
frontend/src/components/RecipeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/RecipeListView.tsx
Normal file
39
frontend/src/components/RecipeListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue