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
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 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:

View File

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

View File

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

View File

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

View File

@ -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");
}
}

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) {
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>
);
}

View File

@ -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 }) {
<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>

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>
<button className="btn primary" onClick={onAddClick}>
+ מתכון חדש
</button>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button className="btn primary" onClick={onAddClick}>
+ מתכון חדש
</button>
</div>
</header>
);
}