Skip to content
File

Blob: src/client/components/toast.tsx

typescript91 lines
1import { useCallback, useEffect, useState } from "react";
2import { CheckCircle, AlertTriangle, Info, X } from "lucide-react";
3 
4type ToastType = "success" | "error" | "info";
5 
6interface ToastItem {
7 id: number;
8 type: ToastType;
9 message: string;
10}
11 
12let nextId = 0;
13let addToastFn: ((type: ToastType, message: string) => void) | null = null;
14 
15export function toast(type: ToastType, message: string) {
16 addToastFn?.(type, message);
17}
18 
19toast.success = (message: string) => toast("success", message);
20toast.error = (message: string) => toast("error", message);
21toast.info = (message: string) => toast("info", message);
22 
23const icons: Record<ToastType, typeof CheckCircle> = {
24 success: CheckCircle,
25 error: AlertTriangle,
26 info: Info,
27};
28 
29const styles: Record<ToastType, string> = {
30 success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
31 error: "border-red-500/30 bg-red-500/10 text-red-300",
32 info: "border-accent-500/30 bg-accent-500/10 text-accent-300",
33};
34 
35export function ToastContainer() {
36 const [toasts, setToasts] = useState<ToastItem[]>([]);
37 
38 const addToast = useCallback((type: ToastType, message: string) => {
39 const id = nextId++;
40 setToasts((prev) => [...prev, { id, type, message }]);
41 }, []);
42 
43 const removeToast = useCallback((id: number) => {
44 setToasts((prev) => prev.filter((t) => t.id !== id));
45 }, []);
46 
47 useEffect(() => {
48 addToastFn = addToast;
49 return () => {
50 addToastFn = null;
51 };
52 }, [addToast]);
53 
54 return (
55 <div
56 role="status"
57 aria-live="polite"
58 className="fixed bottom-4 left-4 right-4 z-[100] flex flex-col items-end gap-2 sm:left-auto"
59 >
60 {toasts.map((item) => (
61 <ToastNotification key={item.id} item={item} onDismiss={removeToast} />
62 ))}
63 </div>
64 );
65}
66 
67function ToastNotification({ item, onDismiss }: { item: ToastItem; onDismiss: (id: number) => void }) {
68 const Icon = icons[item.type];
69 
70 useEffect(() => {
71 const timer = window.setTimeout(() => onDismiss(item.id), 4000);
72 return () => clearTimeout(timer);
73 }, [item.id, onDismiss]);
74 
75 return (
76 <div
77 className={`animate-slide-up flex max-w-sm items-center gap-3 rounded-xl border px-4 py-3 shadow-lg backdrop-blur-sm ${styles[item.type]}`}
78 >
79 <Icon className="h-4 w-4 shrink-0" />
80 <span className="text-sm font-medium">{item.message}</span>
81 <button
82 type="button"
83 className="ml-2 shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100"
84 onClick={() => onDismiss(item.id)}
85 >
86 <X className="h-3.5 w-3.5" />
87 </button>
88 </div>
89 );
90}