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
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 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:

View File

@ -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,
)
@ -31,6 +34,11 @@ class Recipe(RecipeBase):
id: int
class RecipeUpdate(RecipeBase):
pass
app = FastAPI(
title="Random Recipes API",
description="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(

View File

@ -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;
}

View File

@ -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 (
<div className="app-root">
<TopBar onAddClick={() => setDrawerOpen(true)} />
@ -136,14 +176,22 @@ function App() {
<section className="content">
{error && <div className="error-banner">{error}</div>}
<RecipeDetails recipe={selectedRecipe} />
<RecipeDetails
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onDeleteClick={handleDeleteRecipe}
/>
</section>
</main>
<RecipeFormDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleCreateRecipe}
onClose={() => {
setDrawerOpen(false);
setEditingRecipe(null);
}}
onSubmit={handleFormSubmit}
editingRecipe={editingRecipe}
/>
</div>
);

View File

@ -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");
}
}

View File

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

View File

@ -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 }) {
<div className="drawer-backdrop" onClick={onClose}>
<div className="drawer" onClick={(e) => e.stopPropagation()}>
<header className="drawer-header">
<h2>מתכון חדש</h2>
<h2>{isEditMode ? "ערוך מתכון" : "מתכון חדש"}</h2>
<button className="icon-btn" onClick={onClose}>
</button>
@ -182,7 +193,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
ביטול
</button>
<button type="submit" className="btn primary">
שמירת מתכון
{isEditMode ? "עדכן מתכון" : "שמירת מתכון"}
</button>
</footer>
</form>