Add toast notification

This commit is contained in:
dvirlabs 2025-12-05 04:52:27 +02:00
parent f18cee0f86
commit 243b63309c
6 changed files with 231 additions and 6 deletions

View File

@ -193,6 +193,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;
@ -442,3 +448,106 @@ 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;
}
}

View File

@ -5,6 +5,8 @@ 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 Modal from "./components/Modal";
import ToastContainer from "./components/ToastContainer";
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
function App() { function App() {
@ -21,6 +23,9 @@ function App() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editingRecipe, setEditingRecipe] = useState(null); const [editingRecipe, setEditingRecipe] = useState(null);
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
const [toasts, setToasts] = useState([]);
useEffect(() => { useEffect(() => {
loadRecipes(); loadRecipes();
}, []); }, []);
@ -71,8 +76,10 @@ function App() {
setEditingRecipe(null); setEditingRecipe(null);
await loadRecipes(); await loadRecipes();
setSelectedRecipe(created); setSelectedRecipe(created);
addToast("המתכון החדש נוצר בהצלחה!", "success");
} catch { } catch {
setError("שגיאה בשמירת המתכון החדש."); setError("שגיאה בשמירת המתכון החדש.");
addToast("שגיאה בשמירת המתכון החדש", "error");
} }
}; };
@ -91,21 +98,45 @@ function App() {
if (updated) { if (updated) {
setSelectedRecipe(updated); setSelectedRecipe(updated);
} }
addToast("המתכון עודכן בהצלחה!", "success");
} catch { } catch {
setError("שגיאה בעדכון המתכון."); 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 { try {
await deleteRecipe(recipeId); await deleteRecipe(recipeId);
await loadRecipes(); await loadRecipes();
setSelectedRecipe(null); setSelectedRecipe(null);
addToast("המתכון נמחק בהצלחה!", "success");
} catch { } catch {
setError("שגיאה במחיקת המתכון."); 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) => { const handleFormSubmit = (payload) => {
if (editingRecipe) { if (editingRecipe) {
handleUpdateRecipe(payload); handleUpdateRecipe(payload);
@ -179,7 +210,8 @@ function App() {
<RecipeDetails <RecipeDetails
recipe={selectedRecipe} recipe={selectedRecipe}
onEditClick={handleEditRecipe} onEditClick={handleEditRecipe}
onDeleteClick={handleDeleteRecipe} onDeleteClick={handleShowDeleteModal}
onShowDeleteModal={handleShowDeleteModal}
/> />
</section> </section>
</main> </main>
@ -193,6 +225,19 @@ function App() {
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
editingRecipe={editingRecipe} 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

@ -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, onEditClick, onDeleteClick }) { function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
if (!recipe) { if (!recipe) {
return ( return (
<section className="panel placeholder"> <section className="panel placeholder">
@ -8,9 +8,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick }) {
} }
const handleDelete = () => { const handleDelete = () => {
if (confirm("בטוח שאתה רוצה למחוק את המתכון הזה?")) { onShowDeleteModal(recipe.id, recipe.name);
onDeleteClick(recipe.id);
}
}; };
return ( return (

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;