From f18cee0f8640e48e4e1b264928c1152937973766 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 5 Dec 2025 04:39:50 +0200 Subject: [PATCH 1/5] Add buttons --- backend/.env | 5 + backend/__pycache__/db_utils.cpython-313.pyc | Bin 5018 -> 7844 bytes backend/__pycache__/main.cpython-313.pyc | Bin 4943 -> 6260 bytes backend/db_utils.py | 94 +++++++++++++++---- backend/main.py | 35 +++++++ frontend/src/App.css | 11 +++ frontend/src/App.jsx | 56 ++++++++++- frontend/src/api.js | 21 +++++ frontend/src/components/RecipeDetails.jsx | 19 +++- frontend/src/components/RecipeFormDrawer.jsx | 31 ++++-- 10 files changed, 240 insertions(+), 32 deletions(-) diff --git a/backend/.env b/backend/.env index e8b54af..9bcf84f 100644 --- a/backend/.env +++ b/backend/.env @@ -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 diff --git a/backend/__pycache__/db_utils.cpython-313.pyc b/backend/__pycache__/db_utils.cpython-313.pyc index 56c26cafda7dfcd4421a9786bbabf5860a666072..962f7a60fbb8101282838b2a21a28fd4e4dbfa28 100644 GIT binary patch delta 3340 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 delta 1252 zcmZuvNoy2A6t3#-+0rvT`;wU~?P!)kG>J~o(GV9DO(GJe1QXK`$4rxS$VT-9#LIw( zh?i(AFQVQ&sMj3yB>n?$A`PAb-Xa78qM%r>dJ_sC$A|%=))k%!Rd$3rEl1E3p{Mg5f?2m!G@3D z1Cv(LR+5S`X*XmeD~_OZmJ{p{XOf$RxacK$Xfum<8?_j%genPyj9_`-|Ay>Q z%$p!bNC)ZUFVR&}bTo~paiN>NlE$&j{z`-Fw)_l7*>CwtB+kVY{t4w~h$h(!C5Kb& zy)x3IK>{GkLY~Xsb`i2M&qb02n&NQ1;Gt;50YZUvtffP=bpzy<(p+CB~s&=eZfJNIJc%0hKw|ZIZf< z6#i8&V}X~rXT7f}ht(*QyEfc3gZtQ$n(@PF3Nk&-9;@kQod-^6ikg+0*)XDmJa>_O zR3GAY_AWe=Eby3um*bzW+?}jh^CnHK7bDN5eaFuDV1TWsnEi^4*7mdj%+PV+X2Kr#Dk6Nxn?`p8{&c+>l^XcSZ=46*lKcuZ6@=o7(6j99juUQ zy9Tf6C`*g{r0^g#2O!1(48)K)GF@KSH*t=CV1BW>JZBElabAb{vTw1sscd)yX+ISO cOIwJ;w#GiE^3s-{dDE*n&%UScD=@YH0GpfU{{R30 diff --git a/backend/db_utils.py b/backend/db_utils.py index c9133d8..0998071 100644 --- a/backend/db_utils.py +++ b/backend/db_utils.py @@ -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: diff --git a/backend/main.py b/backend/main.py index 0a79750..edfc28f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, ) @@ -30,6 +33,11 @@ class RecipeCreate(RecipeBase): class Recipe(RecipeBase): id: int + +class RecipeUpdate(RecipeBase): + pass + + app = FastAPI( title="Random Recipes 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( diff --git a/frontend/src/App.css b/frontend/src/App.css index 79979c3..929bceb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 455363c..5fc96f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
setDrawerOpen(true)} /> @@ -136,14 +176,22 @@ function App() {
{error &&
{error}
} - +
setDrawerOpen(false)} - onSubmit={handleCreateRecipe} + onClose={() => { + setDrawerOpen(false); + setEditingRecipe(null); + }} + onSubmit={handleFormSubmit} + editingRecipe={editingRecipe} />
); diff --git a/frontend/src/api.js b/frontend/src/api.js index bd56758..398c49f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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"); + } +} \ No newline at end of file diff --git a/frontend/src/components/RecipeDetails.jsx b/frontend/src/components/RecipeDetails.jsx index cb4b276..f125984 100644 --- a/frontend/src/components/RecipeDetails.jsx +++ b/frontend/src/components/RecipeDetails.jsx @@ -1,4 +1,4 @@ -function RecipeDetails({ recipe }) { +function RecipeDetails({ recipe, onEditClick, onDeleteClick }) { if (!recipe) { return (
@@ -7,12 +7,18 @@ function RecipeDetails({ recipe }) { ); } + const handleDelete = () => { + if (confirm("בטוח שאתה רוצה למחוק את המתכון הזה?")) { + onDeleteClick(recipe.id); + } + }; + return (

{recipe.name}

-

+

qwwa {translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה

@@ -51,6 +57,15 @@ function RecipeDetails({ recipe }) { ))} )} + +
+ + +
); } diff --git a/frontend/src/components/RecipeFormDrawer.jsx b/frontend/src/components/RecipeFormDrawer.jsx index 23e3674..7e45de5 100644 --- a/frontend/src/components/RecipeFormDrawer.jsx +++ b/frontend/src/components/RecipeFormDrawer.jsx @@ -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 }) {
e.stopPropagation()}>
-

מתכון חדש

+

{isEditMode ? "ערוך מתכון" : "מתכון חדש"}

@@ -182,7 +193,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) { ביטול From 243b63309cb858a993b524aeff13ded7d549a928 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 5 Dec 2025 04:52:27 +0200 Subject: [PATCH 2/5] Add toast notification --- frontend/src/App.css | 109 +++++++++++++++++++++ frontend/src/App.jsx | 49 ++++++++- frontend/src/components/Modal.jsx | 29 ++++++ frontend/src/components/RecipeDetails.jsx | 6 +- frontend/src/components/Toast.jsx | 24 +++++ frontend/src/components/ToastContainer.jsx | 20 ++++ 6 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/Modal.jsx create mode 100644 frontend/src/components/Toast.jsx create mode 100644 frontend/src/components/ToastContainer.jsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 929bceb..12a2536 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -193,6 +193,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; @@ -442,3 +448,106 @@ 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; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5fc96f7..1b04141 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,8 @@ import TopBar from "./components/TopBar"; import RecipeList from "./components/RecipeList"; import RecipeDetails from "./components/RecipeDetails"; import RecipeFormDrawer from "./components/RecipeFormDrawer"; +import Modal from "./components/Modal"; +import ToastContainer from "./components/ToastContainer"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; function App() { @@ -21,6 +23,9 @@ function App() { const [drawerOpen, setDrawerOpen] = useState(false); const [editingRecipe, setEditingRecipe] = useState(null); + const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" }); + const [toasts, setToasts] = useState([]); + useEffect(() => { loadRecipes(); }, []); @@ -71,8 +76,10 @@ function App() { setEditingRecipe(null); await loadRecipes(); setSelectedRecipe(created); + addToast("המתכון החדש נוצר בהצלחה!", "success"); } catch { setError("שגיאה בשמירת המתכון החדש."); + addToast("שגיאה בשמירת המתכון החדש", "error"); } }; @@ -91,21 +98,45 @@ function App() { if (updated) { setSelectedRecipe(updated); } + addToast("המתכון עודכן בהצלחה!", "success"); } catch { setError("שגיאה בעדכון המתכון."); + addToast("שגיאה בעדכון המתכון", "error"); } }; - const handleDeleteRecipe = async (recipeId) => { + 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); @@ -179,7 +210,8 @@ function App() {
@@ -193,6 +225,19 @@ function App() { onSubmit={handleFormSubmit} editingRecipe={editingRecipe} /> + + + + ); } diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..cc902b1 --- /dev/null +++ b/frontend/src/components/Modal.jsx @@ -0,0 +1,29 @@ +function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false }) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+
+
+ {message} +
+
+ + +
+
+
+ ); +} + +export default Modal; diff --git a/frontend/src/components/RecipeDetails.jsx b/frontend/src/components/RecipeDetails.jsx index f125984..b509203 100644 --- a/frontend/src/components/RecipeDetails.jsx +++ b/frontend/src/components/RecipeDetails.jsx @@ -1,4 +1,4 @@ -function RecipeDetails({ recipe, onEditClick, onDeleteClick }) { +function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) { if (!recipe) { return (
@@ -8,9 +8,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick }) { } const handleDelete = () => { - if (confirm("בטוח שאתה רוצה למחוק את המתכון הזה?")) { - onDeleteClick(recipe.id); - } + onShowDeleteModal(recipe.id, recipe.name); }; return ( diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx new file mode 100644 index 0000000..6847709 --- /dev/null +++ b/frontend/src/components/Toast.jsx @@ -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 ( +
+ + {type === "success" && "✓"} + {type === "error" && "✕"} + {type === "info" && "ℹ"} + + {message} +
+ ); +} + +export default Toast; diff --git a/frontend/src/components/ToastContainer.jsx b/frontend/src/components/ToastContainer.jsx new file mode 100644 index 0000000..baa61ed --- /dev/null +++ b/frontend/src/components/ToastContainer.jsx @@ -0,0 +1,20 @@ +import Toast from "./Toast"; + +function ToastContainer({ toasts, onRemove }) { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +export default ToastContainer; From 346fb57e39f6dadb24bb638ebc15fa4322c13253 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 5 Dec 2025 05:03:58 +0200 Subject: [PATCH 3/5] Place the whole app to the center --- frontend/src/App.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/App.css b/frontend/src/App.css index 12a2536..d73fe26 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; From f5cd32bd58cea69d1dcc8cb6d6c915f30053977e Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 5 Dec 2025 05:41:46 +0200 Subject: [PATCH 4/5] Edit the light mode --- frontend/src/App.css | 151 ++++++++++++++++++++++ frontend/src/App.jsx | 17 ++- frontend/src/components/RecipeDetails.jsx | 2 +- frontend/src/components/ThemeToggle.jsx | 14 ++ frontend/src/components/TopBar.jsx | 8 +- 5 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/ThemeToggle.jsx diff --git a/frontend/src/App.css b/frontend/src/App.css index d73fe26..8dcbcd5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -557,3 +557,154 @@ select { 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: #f9fafb7a; + --bg-elevated: #ffffff7c; + --card: #ffffff54; + --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(107, 114, 128, 0.3); +} + +[data-theme="light"] .btn.ghost:hover { + background: rgba(107, 114, 128, 0.1); +} + +[data-theme="light"] .btn.danger { + background: rgba(220, 38, 38, 0.12); + color: #991b1b; +} + +[data-theme="light"] .btn.danger:hover { + background: rgba(220, 38, 38, 0.2); +} + +[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: #ffffff; + 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); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b04141..fbf8eed 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import RecipeDetails from "./components/RecipeDetails"; import RecipeFormDrawer from "./components/RecipeFormDrawer"; 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() { @@ -25,11 +26,25 @@ function App() { 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(); @@ -147,6 +162,7 @@ function App() { return (
+ setTheme((t) => (t === "dark" ? "light" : "dark"))} /> setDrawerOpen(true)} />
@@ -210,7 +226,6 @@ function App() {
diff --git a/frontend/src/components/RecipeDetails.jsx b/frontend/src/components/RecipeDetails.jsx index b509203..8b83c71 100644 --- a/frontend/src/components/RecipeDetails.jsx +++ b/frontend/src/components/RecipeDetails.jsx @@ -16,7 +16,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }

{recipe.name}

-

qwwa +

{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה

diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..ce9a37e --- /dev/null +++ b/frontend/src/components/ThemeToggle.jsx @@ -0,0 +1,14 @@ +function ThemeToggle({ theme, onToggleTheme }) { + return ( + + ); +} + +export default ThemeToggle; diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index a4c242d..a9ac13e 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -11,9 +11,11 @@ function TopBar({ onAddClick }) { - +
+ +
); } From 46d245ed78a3f3aa975eeb2ca5bedddea11a163f Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 5 Dec 2025 09:10:42 +0200 Subject: [PATCH 5/5] Fix light/dark mode toggle position when add new recipe and style the scroll --- frontend/src/App.css | 65 +++++++++++++++++++++---- frontend/src/App.jsx | 2 +- frontend/src/components/ThemeToggle.jsx | 3 +- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 8dcbcd5..fbe0e70 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -598,9 +598,9 @@ body { } [data-theme="light"] { - --bg: #f9fafb7a; - --bg-elevated: #ffffff7c; - --card: #ffffff54; + --bg: #f9fafbef; + --bg-elevated: #ffffffd7; + --card: #ffffffd5; --card-soft: #f3f4f6; --border-subtle: rgba(107, 114, 128, 0.25); --accent: #059669; @@ -640,20 +640,20 @@ body { [data-theme="light"] .btn.ghost { background: transparent; color: var(--text-main); - border: 1px solid rgba(107, 114, 128, 0.3); + border: 1px solid rgba(214, 210, 208, 0.3); } [data-theme="light"] .btn.ghost:hover { - background: rgba(107, 114, 128, 0.1); + background: rgba(149, 151, 156, 0.1); } [data-theme="light"] .btn.danger { - background: rgba(220, 38, 38, 0.12); - color: #991b1b; + background: rgba(218, 32, 32, 0.486); + color: #881f1f; } [data-theme="light"] .btn.danger:hover { - background: rgba(220, 38, 38, 0.2); + background: rgba(189, 15, 15, 0.644); } [data-theme="light"] input, @@ -686,7 +686,7 @@ body { } [data-theme="light"] .drawer { - background: #ffffff; + background: #d1b29b; border-left: 1px solid rgba(107, 114, 128, 0.2); box-shadow: -4px 0 15px rgba(0, 0, 0, 0.08); } @@ -708,3 +708,50 @@ body { 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; +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fbf8eed..f3784b3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -162,7 +162,7 @@ function App() { return (
- setTheme((t) => (t === "dark" ? "light" : "dark"))} /> + setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} /> setDrawerOpen(true)} />
diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx index ce9a37e..5ad15d4 100644 --- a/frontend/src/components/ThemeToggle.jsx +++ b/frontend/src/components/ThemeToggle.jsx @@ -1,10 +1,11 @@ -function ThemeToggle({ theme, onToggleTheme }) { +function ThemeToggle({ theme, onToggleTheme, hidden = false }) { return (