diff --git a/backend/.env b/backend/.env index e8b54af..9bcf84f 100644 --- a/backend/.env +++ b/backend/.env @@ -1 +1,6 @@ DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db +DB_PASSWORD=Aa123456 +DB_USER=recipes_user +DB_NAME=recipes_db +DB_HOST=localhost +DB_PORT=5432 diff --git a/backend/__pycache__/db_utils.cpython-313.pyc b/backend/__pycache__/db_utils.cpython-313.pyc index 56c26ca..962f7a6 100644 Binary files a/backend/__pycache__/db_utils.cpython-313.pyc and b/backend/__pycache__/db_utils.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index ab172e1..86b1bb0 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/db_utils.py b/backend/db_utils.py index c9133d8..0998071 100644 --- a/backend/db_utils.py +++ b/backend/db_utils.py @@ -1,37 +1,45 @@ import os import json from typing import List, Optional, Dict, Any + import psycopg2 from psycopg2.extras import RealDictCursor from dotenv import load_dotenv +from pathlib import Path -# load .env for local/dev; in K8s you'll use environment variables -load_dotenv() +# Load .env from the backend folder explicitly +BASE_DIR = Path(__file__).resolve().parent +load_dotenv(BASE_DIR / ".env") -# Prefer explicit envs (K8s), fallback to DATABASE_URL (local dev) +# Local/dev: DATABASE_URL +DATABASE_URL = os.getenv("DATABASE_URL") + +# K8s: explicit env vars from Secret DB_HOST = os.getenv("DB_HOST") DB_PORT = os.getenv("DB_PORT", "5432") DB_NAME = os.getenv("DB_NAME") DB_USER = os.getenv("DB_USER") DB_PASSWORD = os.getenv("DB_PASSWORD") -DATABASE_URL = os.getenv( - "DATABASE_URL", - "postgresql://user:password@localhost:5432/recipes_db", -) - def _build_dsn() -> str: + # 1. אם יש DATABASE_URL – משתמשים בזה (לוקאל) + if DATABASE_URL: + print("[DB] Using DATABASE_URL:", DATABASE_URL.replace(DB_PASSWORD or "", "***") if DB_PASSWORD else DATABASE_URL) + return DATABASE_URL + + # 2. אחרת – עובדים עם DB_HOST/DB_* (בקוברנטיס) if DB_HOST and DB_NAME and DB_USER and DB_PASSWORD: - return ( + dsn = ( f"dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD} " f"host={DB_HOST} port={DB_PORT}" ) - if DATABASE_URL: - return DATABASE_URL + print("[DB] Using explicit env vars DSN (masked):", + f"dbname={DB_NAME} user={DB_USER} password=*** host={DB_HOST} port={DB_PORT}") + return dsn + raise RuntimeError( - "No DB configuration found. Set DB_HOST/DB_NAME/DB_USER/DB_PASSWORD " - "or DATABASE_URL." + "No DB configuration found. Set DATABASE_URL or DB_HOST/DB_NAME/DB_USER/DB_PASSWORD." ) @@ -57,11 +65,65 @@ def list_recipes_db() -> List[Dict[str, Any]]: finally: conn.close() +def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + עדכון מתכון קיים לפי id. + recipe_data: name, meal_type, time_minutes, tags, ingredients, steps + """ + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE recipes + SET name = %s, + meal_type = %s, + time_minutes = %s, + tags = %s, + ingredients = %s, + steps = %s + WHERE id = %s + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps + """, + ( + recipe_data["name"], + recipe_data["meal_type"], + recipe_data["time_minutes"], + json.dumps(recipe_data.get("tags", [])), + json.dumps(recipe_data.get("ingredients", [])), + json.dumps(recipe_data.get("steps", [])), + recipe_id, + ), + ) + row = cur.fetchone() + conn.commit() + return row + finally: + conn.close() + + +def delete_recipe_db(recipe_id: int) -> bool: + """ + מחיקת מתכון לפי id. מחזיר True אם נמחק, False אם לא נמצא. + """ + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM recipes + WHERE id = %s + """, + (recipe_id,), + ) + deleted = cur.rowcount > 0 + conn.commit() + return deleted + finally: + conn.close() + def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: - """ - recipe_data keys: name, meal_type, time_minutes, tags, ingredients, steps - """ conn = get_conn() try: with conn.cursor() as cur: diff --git a/backend/main.py b/backend/main.py index 0a79750..edfc28f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from typing import List, Optional from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel +import os import uvicorn @@ -11,6 +12,8 @@ from db_utils import ( list_recipes_db, create_recipe_db, get_recipes_by_filters_db, + update_recipe_db, + delete_recipe_db, ) @@ -30,6 +33,11 @@ class RecipeCreate(RecipeBase): class Recipe(RecipeBase): id: int + +class RecipeUpdate(RecipeBase): + pass + + app = FastAPI( title="Random Recipes API", @@ -80,6 +88,33 @@ def create_recipe(recipe_in: RecipeCreate): steps=row["steps"] or [], ) +@app.put("/recipes/{recipe_id}", response_model=Recipe) +def update_recipe(recipe_id: int, recipe_in: RecipeUpdate): + data = recipe_in.dict() + data["meal_type"] = data["meal_type"].lower() + + row = update_recipe_db(recipe_id, data) + if not row: + raise HTTPException(status_code=404, detail="המתכון לא נמצא") + + return Recipe( + id=row["id"], + name=row["name"], + meal_type=row["meal_type"], + time_minutes=row["time_minutes"], + tags=row["tags"] or [], + ingredients=row["ingredients"] or [], + steps=row["steps"] or [], + ) + + +@app.delete("/recipes/{recipe_id}", status_code=204) +def delete_recipe(recipe_id: int): + deleted = delete_recipe_db(recipe_id) + if not deleted: + raise HTTPException(status_code=404, detail="המתכון לא נמצא") + return + @app.get("/recipes/random", response_model=Recipe) def random_recipe( diff --git a/frontend/src/App.css b/frontend/src/App.css index 79979c3..929bceb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -261,6 +261,9 @@ select { .recipe-card { min-height: 260px; + display: flex; + flex-direction: column; + } .recipe-header { @@ -299,6 +302,7 @@ select { .recipe-body { display: grid; gap: 0.8rem; + flex: 1; } @media (min-width: 720px) { @@ -319,6 +323,13 @@ select { font-size: 0.9rem; } +.recipe-actions { + display: flex; + gap: 0.4rem; + margin-top: 0.8rem; + justify-content: flex-end; +} + .tags { margin-top: 0.6rem; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 455363c..5fc96f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,7 +5,7 @@ import TopBar from "./components/TopBar"; import RecipeList from "./components/RecipeList"; import RecipeDetails from "./components/RecipeDetails"; import RecipeFormDrawer from "./components/RecipeFormDrawer"; -import { getRecipes, getRandomRecipe, createRecipe } from "./api"; +import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; function App() { const [recipes, setRecipes] = useState([]); @@ -19,6 +19,7 @@ function App() { const [error, setError] = useState(""); const [drawerOpen, setDrawerOpen] = useState(false); + const [editingRecipe, setEditingRecipe] = useState(null); useEffect(() => { loadRecipes(); @@ -67,6 +68,7 @@ function App() { try { const created = await createRecipe(payload); setDrawerOpen(false); + setEditingRecipe(null); await loadRecipes(); setSelectedRecipe(created); } catch { @@ -74,6 +76,44 @@ function App() { } }; + const handleEditRecipe = (recipe) => { + setEditingRecipe(recipe); + setDrawerOpen(true); + }; + + const handleUpdateRecipe = async (payload) => { + try { + await updateRecipe(editingRecipe.id, payload); + setDrawerOpen(false); + setEditingRecipe(null); + await loadRecipes(); + const updated = (await getRecipes()).find((r) => r.id === editingRecipe.id); + if (updated) { + setSelectedRecipe(updated); + } + } catch { + setError("שגיאה בעדכון המתכון."); + } + }; + + const handleDeleteRecipe = async (recipeId) => { + try { + await deleteRecipe(recipeId); + await loadRecipes(); + setSelectedRecipe(null); + } catch { + setError("שגיאה במחיקת המתכון."); + } + }; + + const handleFormSubmit = (payload) => { + if (editingRecipe) { + handleUpdateRecipe(payload); + } else { + handleCreateRecipe(payload); + } + }; + return (
setDrawerOpen(true)} /> @@ -136,14 +176,22 @@ function App() {
{error &&
{error}
} - +
setDrawerOpen(false)} - onSubmit={handleCreateRecipe} + onClose={() => { + setDrawerOpen(false); + setEditingRecipe(null); + }} + onSubmit={handleFormSubmit} + editingRecipe={editingRecipe} />
); diff --git a/frontend/src/api.js b/frontend/src/api.js index bd56758..398c49f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -27,3 +27,24 @@ export async function createRecipe(recipe) { const res = await axios.post(`${API_BASE}/recipes`, recipe); return res.data; } + +export async function updateRecipe(id, payload) { + const res = await fetch(`${API_BASE}/recipes/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error("Failed to update recipe"); + } + return res.json(); +} + +export async function deleteRecipe(id) { + const res = await fetch(`${API_BASE}/recipes/${id}`, { + method: "DELETE", + }); + if (!res.ok && res.status !== 204) { + throw new Error("Failed to delete recipe"); + } +} \ No newline at end of file diff --git a/frontend/src/components/RecipeDetails.jsx b/frontend/src/components/RecipeDetails.jsx index cb4b276..f125984 100644 --- a/frontend/src/components/RecipeDetails.jsx +++ b/frontend/src/components/RecipeDetails.jsx @@ -1,4 +1,4 @@ -function RecipeDetails({ recipe }) { +function RecipeDetails({ recipe, onEditClick, onDeleteClick }) { if (!recipe) { return (
@@ -7,12 +7,18 @@ function RecipeDetails({ recipe }) { ); } + const handleDelete = () => { + if (confirm("בטוח שאתה רוצה למחוק את המתכון הזה?")) { + onDeleteClick(recipe.id); + } + }; + return (

{recipe.name}

-

+

qwwa {translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה

@@ -51,6 +57,15 @@ function RecipeDetails({ recipe }) { ))} )} + +
+ + +
); } diff --git a/frontend/src/components/RecipeFormDrawer.jsx b/frontend/src/components/RecipeFormDrawer.jsx index 23e3674..7e45de5 100644 --- a/frontend/src/components/RecipeFormDrawer.jsx +++ b/frontend/src/components/RecipeFormDrawer.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -function RecipeFormDrawer({ open, onClose, onSubmit }) { +function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { const [name, setName] = useState(""); const [mealType, setMealType] = useState("lunch"); const [timeMinutes, setTimeMinutes] = useState(15); @@ -9,16 +9,27 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) { const [ingredients, setIngredients] = useState([""]); const [steps, setSteps] = useState([""]); + const isEditMode = !!editingRecipe; + useEffect(() => { if (open) { - setName(""); - setMealType("lunch"); - setTimeMinutes(15); - setTags(""); - setIngredients([""]); - setSteps([""]); + if (isEditMode) { + setName(editingRecipe.name || ""); + setMealType(editingRecipe.meal_type || "lunch"); + setTimeMinutes(editingRecipe.time_minutes || 15); + setTags((editingRecipe.tags || []).join(", ")); + setIngredients(editingRecipe.ingredients || [""]); + setSteps(editingRecipe.steps || [""]); + } else { + setName(""); + setMealType("lunch"); + setTimeMinutes(15); + setTags(""); + setIngredients([""]); + setSteps([""]); + } } - }, [open]); + }, [open, editingRecipe, isEditMode]); if (!open) return null; @@ -70,7 +81,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
e.stopPropagation()}>
-

מתכון חדש

+

{isEditMode ? "ערוך מתכון" : "מתכון חדש"}

@@ -182,7 +193,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) { ביטול