feat: add Toast notification system
- ToastProvider context with useToast hook - Supports success, error, info, and warning variants - Auto-dismiss with configurable duration (default 4s) - Slide-in animation with progress bar - Stacks up to 5 toasts, oldest dismissed first - Consistent with dark theme UI
This commit is contained in:
145
client/src/components/ToastProvider.jsx
Normal file
145
client/src/components/ToastProvider.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const ToastContext = createContext(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANTS = {
|
||||||
|
success: { bg: '#053321', border: '#0f5132', color: '#9ef7c1', icon: '✓' },
|
||||||
|
error: { bg: '#3c1114', border: '#f5c6cb', color: '#ffb3b8', icon: '✗' },
|
||||||
|
info: { bg: '#0c1f3f', border: '#2563eb', color: '#93c5fd', icon: 'ℹ' },
|
||||||
|
warning: { bg: '#3b2e00', border: '#d4af37', color: '#ffdf8a', icon: '⚠' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
function Toast({ toast, onDismiss }) {
|
||||||
|
const v = VARIANTS[toast.variant] || VARIANTS.info;
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(() => onDismiss(toast.id), 280);
|
||||||
|
}, toast.duration || 4000);
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [toast.id, toast.duration, onDismiss]);
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(() => onDismiss(toast.id), 280);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: v.bg,
|
||||||
|
border: `1px solid ${v.border}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '10px',
|
||||||
|
color: v.color,
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '320px',
|
||||||
|
maxWidth: '480px',
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
|
||||||
|
animation: exiting ? 'toastOut 0.28s ease-in forwards' : 'toastIn 0.28s ease-out',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '16px', lineHeight: 1, flexShrink: 0, marginTop: '1px' }}>{v.icon}</span>
|
||||||
|
<span style={{ flex: 1, lineHeight: 1.5 }}>{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', color: v.color, cursor: 'pointer',
|
||||||
|
fontSize: '16px', padding: '0 0 0 8px', opacity: 0.7, lineHeight: 1, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, height: '3px',
|
||||||
|
background: v.color, opacity: 0.4, borderRadius: '0 0 8px 8px',
|
||||||
|
animation: `toastProgress ${toast.duration || 4000}ms linear forwards`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToast = useCallback((message, variant = 'info', duration = 4000) => {
|
||||||
|
const id = ++nextId;
|
||||||
|
setToasts(prev => {
|
||||||
|
const next = [...prev, { id, message, variant, duration }];
|
||||||
|
return next.length > 5 ? next.slice(-5) : next;
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toast = useCallback({
|
||||||
|
success: (msg, dur) => addToast(msg, 'success', dur),
|
||||||
|
error: (msg, dur) => addToast(msg, 'error', dur || 6000),
|
||||||
|
info: (msg, dur) => addToast(msg, 'info', dur),
|
||||||
|
warning: (msg, dur) => addToast(msg, 'warning', dur || 5000),
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
// Inject keyframes once
|
||||||
|
useEffect(() => {
|
||||||
|
if (document.getElementById('toast-keyframes')) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'toast-keyframes';
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes toastOut {
|
||||||
|
from { opacity: 1; transform: translateX(0); }
|
||||||
|
to { opacity: 0; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes toastProgress {
|
||||||
|
from { width: 100%; }
|
||||||
|
to { width: 0%; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={toast}>
|
||||||
|
{children}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '16px',
|
||||||
|
right: '16px',
|
||||||
|
zIndex: 99999,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div key={t.id} style={{ pointerEvents: 'auto' }}>
|
||||||
|
<Toast toast={t} onDismiss={dismiss} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user