91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||
|
|
import type { Notification } from '@/lib/notifications';
|
||
|
|
|
||
|
|
/* ── Icons ── */
|
||
|
|
|
||
|
|
const ICONS: Record<Notification['type'], { icon: typeof CheckCircle; color: string }> = {
|
||
|
|
success: { icon: CheckCircle, color: 'text-accent-green' },
|
||
|
|
error: { icon: AlertCircle, color: 'text-accent-rose' },
|
||
|
|
info: { icon: Info, color: 'text-accent-cyan' },
|
||
|
|
warning: { icon: AlertTriangle,color: 'text-accent-amber' },
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ════════════════════════════ Toast ════════════════════════════ */
|
||
|
|
|
||
|
|
export default function Toast({
|
||
|
|
notification,
|
||
|
|
onDismiss,
|
||
|
|
}: {
|
||
|
|
notification: Notification;
|
||
|
|
onDismiss: (id: string) => void;
|
||
|
|
}) {
|
||
|
|
const [visible, setVisible] = useState(false);
|
||
|
|
const [progress, setProgress] = useState(100);
|
||
|
|
const { icon: Icon, color } = ICONS[notification.type];
|
||
|
|
const duration = notification.duration ?? 4000;
|
||
|
|
|
||
|
|
/* ── Enter animation ── */
|
||
|
|
useEffect(() => {
|
||
|
|
requestAnimationFrame(() => setVisible(true));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
/* ── Progress bar ── */
|
||
|
|
useEffect(() => {
|
||
|
|
if (duration <= 0) return;
|
||
|
|
const start = Date.now();
|
||
|
|
const frame = () => {
|
||
|
|
const elapsed = Date.now() - start;
|
||
|
|
const pct = Math.max(0, 100 - (elapsed / duration) * 100);
|
||
|
|
setProgress(pct);
|
||
|
|
if (pct > 0) requestAnimationFrame(frame);
|
||
|
|
};
|
||
|
|
const raf = requestAnimationFrame(frame);
|
||
|
|
return () => cancelAnimationFrame(raf);
|
||
|
|
}, [duration]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
role="alert"
|
||
|
|
className={`
|
||
|
|
flex items-start gap-3 p-4 rounded-xl glass border border-surface-700 shadow-xl
|
||
|
|
transition-all duration-300 ease-out max-w-sm
|
||
|
|
${visible ? 'translate-x-0 opacity-100' : 'translate-x-8 opacity-0'}
|
||
|
|
`}
|
||
|
|
>
|
||
|
|
{/* Icon */}
|
||
|
|
<div className="shrink-0 mt-0.5">
|
||
|
|
<Icon className={`w-5 h-5 ${color}`} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Content */}
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<p className="text-sm font-medium text-surface-200">{notification.title}</p>
|
||
|
|
{notification.message && (
|
||
|
|
<p className="text-xs text-surface-400 mt-0.5 break-words">{notification.message}</p>
|
||
|
|
)}
|
||
|
|
{/* Progress bar */}
|
||
|
|
{duration > 0 && (
|
||
|
|
<div className="mt-2 h-0.5 rounded-full bg-surface-700 overflow-hidden">
|
||
|
|
<div
|
||
|
|
className={`h-full rounded-full transition-none ${color.replace('text-', 'bg-')}`}
|
||
|
|
style={{ width: `${progress}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Close */}
|
||
|
|
<button
|
||
|
|
onClick={() => onDismiss(notification.id)}
|
||
|
|
className="shrink-0 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
||
|
|
aria-label="Sluiten"
|
||
|
|
>
|
||
|
|
<X className="w-4 h-4 text-surface-500" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|