cosmo/frontend/src/contexts/ToastContext.tsx

125 lines
4.4 KiB
TypeScript

import { createContext, useContext, useState, useCallback, useRef } from 'react';
import type { ReactNode } 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: ReactNode;
duration?: number;
onClose?: () => void;
}
interface ToastContextValue {
showToast: (message: ReactNode, type?: ToastType, duration?: number, onClose?: () => void) => string;
success: (message: ReactNode, duration?: number, onClose?: () => void) => string;
error: (message: ReactNode, duration?: number, onClose?: () => void) => string;
warning: (message: ReactNode, duration?: number, onClose?: () => void) => string;
info: (message: ReactNode, duration?: number, onClose?: () => void) => string;
removeToast: (id: string) => void;
}
// 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) => {
const toast = prev.find(t => t.id === id);
if (toast && toast.onClose) {
toast.onClose();
}
return prev.filter((t) => t.id !== id);
});
if (timersRef.current.has(id)) {
clearTimeout(timersRef.current.get(id));
timersRef.current.delete(id);
}
}, []);
const showToast = useCallback((message: ReactNode, type: ToastType = 'info', duration = 3000, onClose?: () => void) => {
const id = Math.random().toString(36).substring(2, 9);
const newToast: Toast = { id, type, message, duration, onClose };
setToasts((prev) => [...prev, newToast]);
if (duration > 0) {
const timer = window.setTimeout(() => {
removeToast(id);
}, duration);
timersRef.current.set(id, timer);
}
return id;
}, [removeToast]);
// Convenience methods
const success = useCallback((msg: ReactNode, d?: number, onClose?: () => void) => showToast(msg, 'success', d, onClose), [showToast]);
const error = useCallback((msg: ReactNode, d = 3000, onClose?: () => void) => showToast(msg, 'error', d, onClose), [showToast]);
const warning = useCallback((msg: ReactNode, d = 3000, onClose?: () => void) => showToast(msg, 'warning', d, onClose), [showToast]);
const info = useCallback((msg: ReactNode, d?: number, onClose?: () => void) => showToast(msg, 'info', d, onClose), [showToast]);
return (
<ToastContext.Provider value={{ showToast, success, error, warning, info, removeToast }}>
{children}
{/* Toast Container - Top Right */}
<div className="fixed top-24 right-6 z-[9999] 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>
<div className="flex-1 text-sm font-medium leading-tight pt-0.5">{toast.message}</div>
<button
onClick={() => removeToast(toast.id)}
className="text-white/40 hover:text-white transition-colors shrink-0"
>
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}