146 lines
4.4 KiB
React
146 lines
4.4 KiB
React
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>
|
||
);
|
||
}
|