Add buttons
This commit is contained in:
parent
be720a8e2a
commit
f18cee0f86
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,8 +9,18 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
|
||||
const [ingredients, setIngredients] = useState([""]);
|
||||
const [steps, setSteps] = useState([""]);
|
||||
|
||||
const isEditMode = !!editingRecipe;
|
||||
|
||||
useEffect(() => {
|
||||
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("");
|
||||
setMealType("lunch");
|
||||
setTimeMinutes(15);
|
||||
@ -18,7 +28,8 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user