cosmo/frontend/src/contexts/ToastContext.tsx

117 lines
3.9 KiB
TypeScript
Raw Normal View History

2025-11-30 02:43:47 +00:00
import { createContext, useContext, useState, useCallback, useRef } from 'react';
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
// Types
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType, duration?: number) => string;
success: (message: string, duration?: number) => string;
error: (message: string, duration?: number) => string;
warning: (message: string, duration?: number) => string;
info: (message: string, duration?: number) => string;
removeToast: (id: string) => void;
2025-11-30 02:43:47 +00:00
}
// Context
const ToastContext = createContext<ToastContextValue | null>(null);
// Hook
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
// Icons map
const icons = {
success: <CheckCircle size={20} className="text-green-400" />,
error: <AlertCircle size={20} className="text-red-400" />,
warning: <AlertTriangle size={20} className="text-amber-400" />,
info: <Info size={20} className="text-blue-400" />,
};
// Styles map
const styles = {
success: 'border-green-500/20 bg-green-900/90 text-green-100',
error: 'border-red-500/20 bg-red-900/90 text-red-100',
warning: 'border-amber-500/20 bg-amber-900/90 text-amber-100',
info: 'border-blue-500/20 bg-blue-900/90 text-blue-100',
};
// Provider Component
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<string, number>>(new Map());
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
if (timersRef.current.has(id)) {
clearTimeout(timersRef.current.get(id));
timersRef.current.delete(id);
}
}, []);
const showToast = useCallback((message: string, type: ToastType = 'info', duration = 3000) => {
const id = Math.random().toString(36).substring(2, 9);
const newToast: Toast = { id, type, message, duration };
setToasts((prev) => [...prev, newToast]);
if (duration > 0) {
const timer = window.setTimeout(() => {
removeToast(id);
}, duration);
timersRef.current.set(id, timer);
}
return id;
2025-11-30 02:43:47 +00:00
}, [removeToast]);
// Convenience methods
const success = useCallback((msg: string, d?: number) => showToast(msg, 'success', d), [showToast]);
const error = useCallback((msg: string, d?: number) => showToast(msg, 'error', d), [showToast]);
const warning = useCallback((msg: string, d?: number) => showToast(msg, 'warning', d), [showToast]);
const info = useCallback((msg: string, d?: number) => showToast(msg, 'info', d), [showToast]);
return (
<ToastContext.Provider value={{ showToast, success, error, warning, info, removeToast }}>
2025-11-30 02:43:47 +00:00
{children}
{/* Toast Container - Top Right */}
<div className="fixed top-24 right-6 z-[100] flex flex-col gap-3 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`
pointer-events-auto
flex items-start gap-3 px-4 py-3 rounded-xl border shadow-xl backdrop-blur-md
min-w-[300px] max-w-sm
animate-in slide-in-from-right-8 fade-in duration-300
${styles[toast.type]}
`}
>
<div className="mt-0.5 shrink-0">{icons[toast.type]}</div>
<p className="flex-1 text-sm font-medium leading-tight pt-0.5">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="text-white/40 hover:text-white transition-colors shrink-0"
>
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}