Add toast notification
This commit is contained in:
parent
f18cee0f86
commit
243b63309c
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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, 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 (
|
||||||
|
|||||||
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;
|
||||||
Loading…
x
Reference in New Issue
Block a user