Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions dashboard/src/components/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* components/ToastContainer.tsx — Global toast notification renderer.
*/

import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { X, CheckCircle, AlertTriangle, Info, AlertCircle, Trash2 } from 'lucide-react';
import { useToastStore } from '../store/useToastStore';
import type { ToastType } from '../store/useToastStore';

Expand All @@ -13,14 +14,42 @@ const TYPE_STYLES: Record<ToastType, string> = {
warning: 'border-yellow-500/50 bg-yellow-950/80 text-yellow-200',
};

const TYPE_ICONS: Record<ToastType, typeof CheckCircle> = {
error: AlertCircle,
success: CheckCircle,
info: Info,
warning: AlertTriangle,
};

const AUTO_DISMISS_MS = 4000;

function ToastItem({ id, type, title, description }: { id: string; type: ToastType; title: string; description?: string }) {
const removeToast = useToastStore((s) => s.removeToast);
const [progress, setProgress] = useState(100);
const Icon = TYPE_ICONS[type];

useEffect(() => {
const start = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - start;
const remaining = Math.max(0, 100 - (elapsed / AUTO_DISMISS_MS) * 100);
setProgress(remaining);
if (remaining <= 0) clearInterval(interval);
}, 50);
return () => clearInterval(interval);
}, [id]);

return (
<div
role="alert"
className={`flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm animate-slide-in ${TYPE_STYLES[type]}`}
className={`relative flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm animate-slide-in overflow-hidden ${TYPE_STYLES[type]}`}
>
{/* Progress bar */}
<div
className="absolute bottom-0 left-0 h-0.5 bg-current opacity-30 transition-none"
style={{ width: `${progress}%` }}
/>
<Icon className="mt-0.5 h-4 w-4 shrink-0 opacity-80" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{title}</p>
{description && <p className="mt-0.5 text-xs opacity-80">{description}</p>}
Expand All @@ -38,11 +67,24 @@ function ToastItem({ id, type, title, description }: { id: string; type: ToastTy

export default function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);

if (toasts.length === 0) return null;

return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.length > 1 && (
<div className="flex justify-end pointer-events-auto">
<button
onClick={() => toasts.forEach((t) => removeToast(t.id))}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
aria-label="Dismiss all notifications"
>
<Trash2 className="h-3 w-3" />
Clear all
</button>
</div>
)}
{toasts.map((t) => (
<div key={t.id} className="pointer-events-auto">
<ToastItem id={t.id} type={t.type} title={t.title} description={t.description} />
Expand Down
Loading