Merge pull request 'add-buttons' (#1) from add-buttons into master
Reviewed-on: #1
This commit is contained in:
commit
c6eaae7321
@ -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(
|
||||
|
||||
@ -26,6 +26,12 @@ body {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Center the app horizontally (keeps top alignment) */
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
max-width: 1200px;
|
||||
@ -193,6 +199,12 @@ select {
|
||||
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: linear-gradient(135deg, var(--danger), #e74c3c);
|
||||
color: #fde8e8;
|
||||
box-shadow: 0 12px 25px rgba(249, 115, 115, 0.4);
|
||||
}
|
||||
|
||||
.btn.small {
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
@ -261,6 +273,9 @@ select {
|
||||
|
||||
.recipe-card {
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
.recipe-header {
|
||||
@ -299,6 +314,7 @@ select {
|
||||
.recipe-body {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@ -319,6 +335,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;
|
||||
}
|
||||
@ -431,3 +454,304 @@ select {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
|
||||
max-width: 400px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
pointer-events: auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: rgba(249, 115, 115, 0.1);
|
||||
border-color: rgba(249, 115, 115, 0.5);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Toggle (fixed floating button) */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 100;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--card);
|
||||
color: var(--text-main);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Update body to apply bg properly in both themes */
|
||||
body {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] body {
|
||||
background: radial-gradient(circle at top, #0f172a 0, #020617 55%);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f9fafbef;
|
||||
--bg-elevated: #ffffffd7;
|
||||
--card: #ffffffd5;
|
||||
--card-soft: #f3f4f6;
|
||||
--border-subtle: rgba(107, 114, 128, 0.25);
|
||||
--accent: #059669;
|
||||
--accent-soft: rgba(5, 150, 105, 0.12);
|
||||
--accent-strong: #047857;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #6b7280;
|
||||
--danger: #dc2626;
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
background: linear-gradient(180deg, #ac8d75 0%, #f6f8fa 100%);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
[data-theme="light"] .topbar {
|
||||
background: linear-gradient(90deg, #e7be9e, #e2b08a);
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.accent {
|
||||
background: var(--accent-soft);
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.accent:hover {
|
||||
background: rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid rgba(214, 210, 208, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.ghost:hover {
|
||||
background: rgba(149, 151, 156, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.danger {
|
||||
background: rgba(218, 32, 32, 0.486);
|
||||
color: #881f1f;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn.danger:hover {
|
||||
background: rgba(189, 15, 15, 0.644);
|
||||
}
|
||||
|
||||
[data-theme="light"] input,
|
||||
[data-theme="light"] select {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
background: #d4cfcf;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
[data-theme="light"] input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-theme="light"] option {
|
||||
background: #ffffff;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
[data-theme="light"] .panel {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(107, 114, 128, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .modal {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(107, 114, 128, 0.2);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .drawer {
|
||||
background: #d1b29b;
|
||||
border-left: 1px solid rgba(107, 114, 128, 0.2);
|
||||
box-shadow: -4px 0 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .error-banner {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border: 1px solid rgba(248, 113, 113, 0.5);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
[data-theme="light"] .pill {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
[data-theme="light"] .tag {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
[data-theme="light"] .toast.success {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
border-color: rgba(5, 150, 105, 0.5);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-list-item:hover {
|
||||
background: rgba(229, 231, 235, 0.6);
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
|
||||
/* Light mode scrollbar */
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(107, 114, 128, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
html {
|
||||
scrollbar-color: rgba(148, 163, 184, 0.5) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
[data-theme="light"] html {
|
||||
scrollbar-color: rgba(107, 114, 128, 0.4) transparent;
|
||||
}
|
||||
@ -5,7 +5,10 @@ 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 Modal from "./components/Modal";
|
||||
import ToastContainer from "./components/ToastContainer";
|
||||
import ThemeToggle from "./components/ThemeToggle";
|
||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||
|
||||
function App() {
|
||||
const [recipes, setRecipes] = useState([]);
|
||||
@ -19,11 +22,29 @@ function App() {
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [theme, setTheme] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("theme") || "dark";
|
||||
} catch {
|
||||
return "dark";
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
try {
|
||||
localStorage.setItem("theme", theme);
|
||||
} catch {}
|
||||
}, [theme]);
|
||||
|
||||
const loadRecipes = async () => {
|
||||
try {
|
||||
const list = await getRecipes();
|
||||
@ -67,15 +88,81 @@ function App() {
|
||||
try {
|
||||
const created = await createRecipe(payload);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
setSelectedRecipe(created);
|
||||
addToast("המתכון החדש נוצר בהצלחה!", "success");
|
||||
} catch {
|
||||
setError("שגיאה בשמירת המתכון החדש.");
|
||||
addToast("שגיאה בשמירת המתכון החדש", "error");
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
addToast("המתכון עודכן בהצלחה!", "success");
|
||||
} catch {
|
||||
setError("שגיאה בעדכון המתכון.");
|
||||
addToast("שגיאה בעדכון המתכון", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDeleteModal = (recipeId, recipeName) => {
|
||||
setDeleteModal({ isOpen: true, recipeId, recipeName });
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
const recipeId = deleteModal.recipeId;
|
||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
|
||||
try {
|
||||
await deleteRecipe(recipeId);
|
||||
await loadRecipes();
|
||||
setSelectedRecipe(null);
|
||||
addToast("המתכון נמחק בהצלחה!", "success");
|
||||
} catch {
|
||||
setError("שגיאה במחיקת המתכון.");
|
||||
addToast("שגיאה במחיקת המתכון", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
};
|
||||
|
||||
const addToast = (message, type = "info", duration = 3000) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
||||
};
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
const handleFormSubmit = (payload) => {
|
||||
if (editingRecipe) {
|
||||
handleUpdateRecipe(payload);
|
||||
} else {
|
||||
handleCreateRecipe(payload);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
||||
|
||||
<main className="layout">
|
||||
@ -136,15 +223,36 @@ function App() {
|
||||
|
||||
<section className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<RecipeDetails recipe={selectedRecipe} />
|
||||
<RecipeDetails
|
||||
recipe={selectedRecipe}
|
||||
onEditClick={handleEditRecipe}
|
||||
onShowDeleteModal={handleShowDeleteModal}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<RecipeFormDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSubmit={handleCreateRecipe}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
editingRecipe={editingRecipe}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="מחק מתכון"
|
||||
message={`בטוח שאתה רוצה למחוק את "${deleteModal.recipeName}"?`}
|
||||
confirmText="מחק"
|
||||
cancelText="ביטול"
|
||||
isDangerous={true}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</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");
|
||||
}
|
||||
}
|
||||
29
frontend/src/components/Modal.jsx
Normal file
29
frontend/src/components/Modal.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{message}
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<button className="btn ghost" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${isDangerous ? "danger" : "primary"}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
@ -1,4 +1,4 @@
|
||||
function RecipeDetails({ recipe }) {
|
||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
||||
if (!recipe) {
|
||||
return (
|
||||
<section className="panel placeholder">
|
||||
@ -7,6 +7,10 @@ function RecipeDetails({ recipe }) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
onShowDeleteModal(recipe.id, recipe.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel recipe-card">
|
||||
<header className="recipe-header">
|
||||
@ -51,6 +55,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>
|
||||
|
||||
15
frontend/src/components/ThemeToggle.jsx
Normal file
15
frontend/src/components/ThemeToggle.jsx
Normal file
@ -0,0 +1,15 @@
|
||||
function ThemeToggle({ theme, onToggleTheme, hidden = false }) {
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
title={theme === "dark" ? "הפעל מצב בהיר" : "הפעל מצב חשוך"}
|
||||
onClick={onToggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
style={{ display: hidden ? "none" : "flex" }}
|
||||
>
|
||||
{theme === "dark" ? "🌤" : "🌙"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
24
frontend/src/components/Toast.jsx
Normal file
24
frontend/src/components/Toast.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
function Toast({ id, message, type = "info", duration = 3000, onClose }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose(id);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [id, duration, onClose]);
|
||||
|
||||
return (
|
||||
<div className={`toast ${type}`}>
|
||||
<span>
|
||||
{type === "success" && "✓"}
|
||||
{type === "error" && "✕"}
|
||||
{type === "info" && "ℹ"}
|
||||
</span>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toast;
|
||||
20
frontend/src/components/ToastContainer.jsx
Normal file
20
frontend/src/components/ToastContainer.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Toast from "./Toast";
|
||||
|
||||
function ToastContainer({ toasts, onRemove }) {
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
id={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToastContainer;
|
||||
@ -11,9 +11,11 @@ function TopBar({ onAddClick }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<button className="btn primary" onClick={onAddClick}>
|
||||
+ מתכון חדש
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user