Add buttons

This commit is contained in:
dvirlabs 2025-12-05 04:39:50 +02:00
parent be720a8e2a
commit f18cee0f86
10 changed files with 240 additions and 32 deletions

View File

@ -1 +1,6 @@
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db 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

View File

@ -1,37 +1,45 @@
import os import os
import json import json
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
import psycopg2 import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from dotenv import load_dotenv from dotenv import load_dotenv
from pathlib import Path
# load .env for local/dev; in K8s you'll use environment variables # Load .env from the backend folder explicitly
load_dotenv() 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_HOST = os.getenv("DB_HOST")
DB_PORT = os.getenv("DB_PORT", "5432") DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME") DB_NAME = os.getenv("DB_NAME")
DB_USER = os.getenv("DB_USER") DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD") DB_PASSWORD = os.getenv("DB_PASSWORD")
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://user:password@localhost:5432/recipes_db",
)
def _build_dsn() -> str: 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: 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"dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD} "
f"host={DB_HOST} port={DB_PORT}" f"host={DB_HOST} port={DB_PORT}"
) )
if DATABASE_URL: print("[DB] Using explicit env vars DSN (masked):",
return DATABASE_URL f"dbname={DB_NAME} user={DB_USER} password=*** host={DB_HOST} port={DB_PORT}")
return dsn
raise RuntimeError( raise RuntimeError(
"No DB configuration found. Set DB_HOST/DB_NAME/DB_USER/DB_PASSWORD " "No DB configuration found. Set DATABASE_URL or DB_HOST/DB_NAME/DB_USER/DB_PASSWORD."
"or DATABASE_URL."
) )
@ -57,11 +65,65 @@ def list_recipes_db() -> List[Dict[str, Any]]:
finally: finally:
conn.close() 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]: 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() conn = get_conn()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:

View File

@ -4,6 +4,7 @@ from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
import os
import uvicorn import uvicorn
@ -11,6 +12,8 @@ from db_utils import (
list_recipes_db, list_recipes_db,
create_recipe_db, create_recipe_db,
get_recipes_by_filters_db, get_recipes_by_filters_db,
update_recipe_db,
delete_recipe_db,
) )
@ -31,6 +34,11 @@ class Recipe(RecipeBase):
id: int id: int
class RecipeUpdate(RecipeBase):
pass
app = FastAPI( app = FastAPI(
title="Random Recipes API", title="Random Recipes API",
description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋", description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋",
@ -80,6 +88,33 @@ def create_recipe(recipe_in: RecipeCreate):
steps=row["steps"] or [], 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) @app.get("/recipes/random", response_model=Recipe)
def random_recipe( def random_recipe(

View File

@ -261,6 +261,9 @@ select {
.recipe-card { .recipe-card {
min-height: 260px; min-height: 260px;
display: flex;
flex-direction: column;
} }
.recipe-header { .recipe-header {
@ -299,6 +302,7 @@ select {
.recipe-body { .recipe-body {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
flex: 1;
} }
@media (min-width: 720px) { @media (min-width: 720px) {
@ -319,6 +323,13 @@ select {
font-size: 0.9rem; font-size: 0.9rem;
} }
.recipe-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.8rem;
justify-content: flex-end;
}
.tags { .tags {
margin-top: 0.6rem; margin-top: 0.6rem;
} }

View File

@ -5,7 +5,7 @@ import TopBar from "./components/TopBar";
import RecipeList from "./components/RecipeList"; import RecipeList from "./components/RecipeList";
import RecipeDetails from "./components/RecipeDetails"; import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer"; import RecipeFormDrawer from "./components/RecipeFormDrawer";
import { getRecipes, getRandomRecipe, createRecipe } from "./api"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
function App() { function App() {
const [recipes, setRecipes] = useState([]); const [recipes, setRecipes] = useState([]);
@ -19,6 +19,7 @@ function App() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editingRecipe, setEditingRecipe] = useState(null);
useEffect(() => { useEffect(() => {
loadRecipes(); loadRecipes();
@ -67,6 +68,7 @@ function App() {
try { try {
const created = await createRecipe(payload); const created = await createRecipe(payload);
setDrawerOpen(false); setDrawerOpen(false);
setEditingRecipe(null);
await loadRecipes(); await loadRecipes();
setSelectedRecipe(created); setSelectedRecipe(created);
} catch { } 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 ( return (
<div className="app-root"> <div className="app-root">
<TopBar onAddClick={() => setDrawerOpen(true)} /> <TopBar onAddClick={() => setDrawerOpen(true)} />
@ -136,14 +176,22 @@ function App() {
<section className="content"> <section className="content">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
<RecipeDetails recipe={selectedRecipe} /> <RecipeDetails
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onDeleteClick={handleDeleteRecipe}
/>
</section> </section>
</main> </main>
<RecipeFormDrawer <RecipeFormDrawer
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => {
onSubmit={handleCreateRecipe} setDrawerOpen(false);
setEditingRecipe(null);
}}
onSubmit={handleFormSubmit}
editingRecipe={editingRecipe}
/> />
</div> </div>
); );

View File

@ -27,3 +27,24 @@ export async function createRecipe(recipe) {
const res = await axios.post(`${API_BASE}/recipes`, recipe); const res = await axios.post(`${API_BASE}/recipes`, recipe);
return res.data; 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");
}
}

View File

@ -1,4 +1,4 @@
function RecipeDetails({ recipe }) { function RecipeDetails({ recipe, onEditClick, onDeleteClick }) {
if (!recipe) { if (!recipe) {
return ( return (
<section className="panel placeholder"> <section className="panel placeholder">
@ -7,12 +7,18 @@ function RecipeDetails({ recipe }) {
); );
} }
const handleDelete = () => {
if (confirm("בטוח שאתה רוצה למחוק את המתכון הזה?")) {
onDeleteClick(recipe.id);
}
};
return ( return (
<section className="panel recipe-card"> <section className="panel recipe-card">
<header className="recipe-header"> <header className="recipe-header">
<div> <div>
<h2>{recipe.name}</h2> <h2>{recipe.name}</h2>
<p className="recipe-subtitle"> <p className="recipe-subtitle">qwwa
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה {translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
</p> </p>
</div> </div>
@ -51,6 +57,15 @@ function RecipeDetails({ recipe }) {
))} ))}
</footer> </footer>
)} )}
<div className="recipe-actions">
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
ערוך
</button>
<button className="btn ghost small" onClick={handleDelete}>
🗑 מחק
</button>
</div>
</section> </section>
); );
} }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
function RecipeFormDrawer({ open, onClose, onSubmit }) { function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [mealType, setMealType] = useState("lunch"); const [mealType, setMealType] = useState("lunch");
const [timeMinutes, setTimeMinutes] = useState(15); const [timeMinutes, setTimeMinutes] = useState(15);
@ -9,8 +9,18 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
const [ingredients, setIngredients] = useState([""]); const [ingredients, setIngredients] = useState([""]);
const [steps, setSteps] = useState([""]); const [steps, setSteps] = useState([""]);
const isEditMode = !!editingRecipe;
useEffect(() => { useEffect(() => {
if (open) { if (open) {
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(""); setName("");
setMealType("lunch"); setMealType("lunch");
setTimeMinutes(15); setTimeMinutes(15);
@ -18,7 +28,8 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
setIngredients([""]); setIngredients([""]);
setSteps([""]); setSteps([""]);
} }
}, [open]); }
}, [open, editingRecipe, isEditMode]);
if (!open) return null; if (!open) return null;
@ -70,7 +81,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
<div className="drawer-backdrop" onClick={onClose}> <div className="drawer-backdrop" onClick={onClose}>
<div className="drawer" onClick={(e) => e.stopPropagation()}> <div className="drawer" onClick={(e) => e.stopPropagation()}>
<header className="drawer-header"> <header className="drawer-header">
<h2>מתכון חדש</h2> <h2>{isEditMode ? "ערוך מתכון" : "מתכון חדש"}</h2>
<button className="icon-btn" onClick={onClose}> <button className="icon-btn" onClick={onClose}>
</button> </button>
@ -182,7 +193,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
ביטול ביטול
</button> </button>
<button type="submit" className="btn primary"> <button type="submit" className="btn primary">
שמירת מתכון {isEditMode ? "עדכן מתכון" : "שמירת מתכון"}
</button> </button>
</footer> </footer>
</form> </form>