85 lines
2.9 KiB
JavaScript
85 lines
2.9 KiB
JavaScript
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
|
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
|
|
|
|
const ToastContext = createContext(null)
|
|
|
|
const DEFAULT_DURATION = 3500
|
|
|
|
function ToastItem({ toast, onClose }) {
|
|
const icon = toast.type === 'success'
|
|
? <CheckCircle2 size={16} />
|
|
: toast.type === 'error'
|
|
? <AlertTriangle size={16} />
|
|
: <Info size={16} />
|
|
|
|
const toneStyle = toast.type === 'success'
|
|
? { border: '1px solid rgba(37,211,102,0.3)', color: '#86efac' }
|
|
: toast.type === 'error'
|
|
? { border: '1px solid rgba(239,68,68,0.35)', color: '#fda4af' }
|
|
: { border: '1px solid rgba(148,163,184,0.35)', color: 'var(--color-text-secondary)' }
|
|
|
|
return (
|
|
<div
|
|
className="min-w-[260px] max-w-[360px] rounded-xl px-3 py-2.5 flex items-start gap-2.5"
|
|
style={{
|
|
background: 'var(--color-surface)',
|
|
boxShadow: '0 12px 32px rgba(0,0,0,0.35)',
|
|
...toneStyle,
|
|
}}
|
|
>
|
|
<div className="mt-0.5 shrink-0">{icon}</div>
|
|
<div className="text-sm font-semibold leading-5 text-text-primary flex-1">{toast.message}</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onClose(toast.id)}
|
|
className="border-none bg-transparent text-text-muted hover:text-text-primary cursor-pointer p-0.5"
|
|
aria-label="Dismiss notification"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ToastProvider({ children }) {
|
|
const [toasts, setToasts] = useState([])
|
|
|
|
const dismiss = useCallback((id) => {
|
|
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
|
}, [])
|
|
|
|
const push = useCallback((message, type = 'info', duration = DEFAULT_DURATION) => {
|
|
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
setToasts((prev) => [...prev, { id, message, type }])
|
|
window.setTimeout(() => dismiss(id), duration)
|
|
}, [dismiss])
|
|
|
|
const value = useMemo(() => ({
|
|
toast: (message, type = 'info', duration = DEFAULT_DURATION) => push(message, type, duration),
|
|
success: (message, duration = DEFAULT_DURATION) => push(message, 'success', duration),
|
|
error: (message, duration = DEFAULT_DURATION) => push(message, 'error', duration),
|
|
info: (message, duration = DEFAULT_DURATION) => push(message, 'info', duration),
|
|
}), [push])
|
|
|
|
return (
|
|
<ToastContext.Provider value={value}>
|
|
{children}
|
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
|
{toasts.map((toast) => (
|
|
<div key={toast.id} className="pointer-events-auto">
|
|
<ToastItem toast={toast} onClose={dismiss} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useToast() {
|
|
const context = useContext(ToastContext)
|
|
if (!context) {
|
|
throw new Error('useToast must be used within ToastProvider')
|
|
}
|
|
return context
|
|
}
|