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