125 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|