71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { X, CheckCircle, AlertTriangle, Info } from 'lucide-react'
|
|
import { addToastListener, type ToastMessage } from '../hooks/useSSE'
|
|
|
|
const ICONS = {
|
|
info: Info,
|
|
success: CheckCircle,
|
|
warning: AlertTriangle,
|
|
}
|
|
|
|
const STYLES = {
|
|
info: 'border-blue-600 bg-blue-900/40 text-blue-100',
|
|
success: 'border-emerald-600 bg-emerald-900/40 text-emerald-100',
|
|
warning: 'border-amber-600 bg-amber-900/40 text-amber-100',
|
|
}
|
|
|
|
export default function ToastContainer() {
|
|
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
|
|
|
const remove = useCallback((id: string) => {
|
|
setToasts(prev => prev.filter(t => t.id !== id))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return addToastListener((toast) => {
|
|
setToasts(prev => {
|
|
// Prevent duplicate identical toasts within 3 seconds
|
|
const now = Date.now()
|
|
const recent = prev.filter(
|
|
t => t.title === toast.title && now - parseInt(t.id.split('-').pop() || '0', 10) < 3000
|
|
)
|
|
if (recent.length > 0) return prev
|
|
return [...prev.slice(-4), toast]
|
|
})
|
|
// Auto-dismiss after 5 seconds
|
|
setTimeout(() => remove(toast.id), 5000)
|
|
})
|
|
}, [remove])
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
|
{toasts.map((toast) => {
|
|
const Icon = ICONS[toast.type]
|
|
return (
|
|
<div
|
|
key={toast.id}
|
|
className={`pointer-events-auto rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm transition-all ${STYLES[toast.type]}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<Icon size={18} className="mt-0.5 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold leading-snug">{toast.title}</p>
|
|
{toast.body && (
|
|
<p className="text-xs opacity-80 mt-0.5">{toast.body}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => remove(toast.id)}
|
|
className="shrink-0 -mr-1 -mt-1 p-1 rounded hover:bg-white/10 text-current opacity-70 hover:opacity-100 transition-opacity"
|
|
aria-label="Dismiss"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|