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
|
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 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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
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) {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user