Spaces:
Running
Running
File size: 5,614 Bytes
f381be8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | /**
* Minimal toast / notification system.
* Use the `useToast` hook to fire toasts from any component.
*/
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { X, CheckCircle2, AlertTriangle, XCircle, Info } from "lucide-react";
// ββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type ToastType = "success" | "error" | "warning" | "info";
export interface Toast {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number; // ms β 0 = sticky
}
interface ToastCtx {
toasts: Toast[];
toast: (t: Omit<Toast, "id">) => string;
dismiss: (id: string) => void;
}
// ββ Context ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const ToastContext = createContext<ToastCtx | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
// ββ Icon helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function ToastIcon({ type }: { type: ToastType }) {
const cls = "w-4 h-4 shrink-0 mt-0.5";
if (type === "success") return <CheckCircle2 className={`${cls} text-green-400`} />;
if (type === "error") return <XCircle className={`${cls} text-red-400`} />;
if (type === "warning") return <AlertTriangle className={`${cls} text-yellow-400`} />;
return <Info className={`${cls} text-blue-400`} />;
}
const BORDER_COLOR: Record<ToastType, string> = {
success: "border-green-500/40",
error: "border-red-500/40",
warning: "border-yellow-500/40",
info: "border-blue-500/40",
};
const BG_COLOR: Record<ToastType, string> = {
success: "bg-green-500/10",
error: "bg-red-500/10",
warning: "bg-yellow-500/10",
info: "bg-blue-500/10",
};
// ββ Single toast item βββββββββββββββββββββββββββββββββββββββββββββββββββββ
function ToastItem({ t, onDismiss }: { t: Toast; onDismiss: () => void }) {
const [visible, setVisible] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Slight delay so CSS enter transition fires
const raf = requestAnimationFrame(() => setVisible(true));
const dur = t.duration ?? 4500;
if (dur > 0) {
timerRef.current = setTimeout(() => setVisible(false), dur);
}
return () => {
cancelAnimationFrame(raf);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [t.duration]);
// When exit animation ends, notify parent
const handleTransitionEnd = () => {
if (!visible) onDismiss();
};
return (
<div
onTransitionEnd={handleTransitionEnd}
style={{
transition: "opacity 200ms ease, transform 200ms ease",
opacity: visible ? 1 : 0,
transform: visible ? "translateX(0)" : "translateX(16px)",
}}
className={`
flex items-start gap-3 w-80 rounded-xl border px-4 py-3 shadow-2xl
${BORDER_COLOR[t.type]} ${BG_COLOR[t.type]}
bg-gray-900 backdrop-blur-sm
`}
>
<ToastIcon type={t.type} />
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white leading-tight">{t.title}</div>
{t.message && (
<div className="text-xs text-gray-400 mt-0.5 leading-snug">{t.message}</div>
)}
</div>
<button
onClick={() => setVisible(false)}
className="shrink-0 text-gray-500 hover:text-white transition-colors mt-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// ββ Container ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function ToastContainer({ toasts, dismiss }: { toasts: Toast[]; dismiss: (id: string) => void }) {
if (!toasts.length) return null;
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
{toasts.map((t) => (
<div key={t.id} className="pointer-events-auto">
<ToastItem t={t} onDismiss={() => dismiss(t.id)} />
</div>
))}
</div>
);
}
// ββ Provider βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((t: Omit<Toast, "id">): string => {
const id = `t-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setToasts((p) => [...p, { ...t, id }]);
return id;
}, []);
const dismiss = useCallback((id: string) => {
setToasts((p) => p.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
<ToastContainer toasts={toasts} dismiss={dismiss} />
</ToastContext.Provider>
);
}
|