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