Merge pull request 'add-buttons' (#1) from add-buttons into master

Reviewed-on: #1
This commit is contained in:
dvirlabs 2025-12-05 07:11:42 +00:00
commit c6eaae7321
15 changed files with 703 additions and 34 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,
) )
@ -30,6 +33,11 @@ class RecipeCreate(RecipeBase):
class Recipe(RecipeBase): class Recipe(RecipeBase):
id: int id: int
class RecipeUpdate(RecipeBase):
pass
app = FastAPI( app = FastAPI(
title="Random Recipes API", title="Random Recipes 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

@ -26,6 +26,12 @@ body {
color: var(--text-main); color: var(--text-main);
} }
/* Center the app horizontally (keeps top alignment) */
body {
display: flex;
justify-content: center;
align-items: flex-start;
}
.app-root { .app-root {
min-height: 100vh; min-height: 100vh;
max-width: 1200px; max-width: 1200px;
@ -193,6 +199,12 @@ select {
border: 1px solid rgba(148, 163, 184, 0.5); 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 { .btn.small {
padding: 0.25rem 0.7rem; padding: 0.25rem 0.7rem;
font-size: 0.8rem; font-size: 0.8rem;
@ -261,6 +273,9 @@ select {
.recipe-card { .recipe-card {
min-height: 260px; min-height: 260px;
display: flex;
flex-direction: column;
} }
.recipe-header { .recipe-header {
@ -299,6 +314,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 +335,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;
} }
@ -431,3 +454,304 @@ select {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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;
}

View File

@ -5,7 +5,10 @@ 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 Modal from "./components/Modal";
import ToastContainer from "./components/ToastContainer";
import ThemeToggle from "./components/ThemeToggle";
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
function App() { function App() {
const [recipes, setRecipes] = useState([]); const [recipes, setRecipes] = useState([]);
@ -19,11 +22,29 @@ 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);
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(() => { useEffect(() => {
loadRecipes(); loadRecipes();
}, []); }, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("theme", theme);
} catch {}
}, [theme]);
const loadRecipes = async () => { const loadRecipes = async () => {
try { try {
const list = await getRecipes(); const list = await getRecipes();
@ -67,15 +88,81 @@ 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);
addToast("המתכון החדש נוצר בהצלחה!", "success");
} catch { } catch {
setError("שגיאה בשמירת המתכון החדש."); 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 ( return (
<div className="app-root"> <div className="app-root">
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
<TopBar onAddClick={() => setDrawerOpen(true)} /> <TopBar onAddClick={() => setDrawerOpen(true)} />
<main className="layout"> <main className="layout">
@ -136,15 +223,36 @@ 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}
onShowDeleteModal={handleShowDeleteModal}
/>
</section> </section>
</main> </main>
<RecipeFormDrawer <RecipeFormDrawer
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => {
onSubmit={handleCreateRecipe} 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> </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

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

View File

@ -1,4 +1,4 @@
function RecipeDetails({ recipe }) { function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
if (!recipe) { if (!recipe) {
return ( return (
<section className="panel placeholder"> <section className="panel placeholder">
@ -7,6 +7,10 @@ function RecipeDetails({ recipe }) {
); );
} }
const handleDelete = () => {
onShowDeleteModal(recipe.id, recipe.name);
};
return ( return (
<section className="panel recipe-card"> <section className="panel recipe-card">
<header className="recipe-header"> <header className="recipe-header">
@ -51,6 +55,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,16 +9,27 @@ 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) {
setName(""); if (isEditMode) {
setMealType("lunch"); setName(editingRecipe.name || "");
setTimeMinutes(15); setMealType(editingRecipe.meal_type || "lunch");
setTags(""); setTimeMinutes(editingRecipe.time_minutes || 15);
setIngredients([""]); setTags((editingRecipe.tags || []).join(", "));
setSteps([""]); setIngredients(editingRecipe.ingredients || [""]);
setSteps(editingRecipe.steps || [""]);
} else {
setName("");
setMealType("lunch");
setTimeMinutes(15);
setTags("");
setIngredients([""]);
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>

View 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;

View 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;

View 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;

View File

@ -11,9 +11,11 @@ function TopBar({ onAddClick }) {
</div> </div>
</div> </div>
<button className="btn primary" onClick={onAddClick}> <div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
+ מתכון חדש <button className="btn primary" onClick={onAddClick}>
</button> + מתכון חדש
</button>
</div>
</header> </header>
); );
} }