Files
Netcatty/components/ui/toast.tsx
LAPTOP-O016UC3M\Qi Chen b8c221d112 Organize and normalize imports across project
Reorder and normalize import statements, group icon imports, and add
missing utility imports (cn). Fix export/newline formatting and minor
type import tweaks. Add ESLint scripts to package.json and update the
lockfile.
2025-12-11 16:01:03 +08:00

135 lines
4.7 KiB
TypeScript

import { AlertCircle,AlertTriangle,CheckCircle,Info,X } from 'lucide-react';
import React,{ createContext,useCallback,useContext,useEffect,useState } from 'react';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
title?: string;
message: string;
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
showToast: (toast: Omit<Toast, 'id'>) => void;
dismissToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
export const toast = {
success: (message: string, title?: string) => {
globalShowToast?.({ type: 'success', message, title, duration: 3000 });
},
error: (message: string, title?: string) => {
globalShowToast?.({ type: 'error', message, title, duration: 5000 });
},
warning: (message: string, title?: string) => {
globalShowToast?.({ type: 'warning', message, title, duration: 4000 });
},
info: (message: string, title?: string) => {
globalShowToast?.({ type: 'info', message, title, duration: 3000 });
},
};
const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="h-4 w-4 text-emerald-500" />,
error: <AlertCircle className="h-4 w-4 text-red-500" />,
warning: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
info: <Info className="h-4 w-4 text-blue-500" />,
};
const TOAST_STYLES: Record<ToastType, string> = {
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
error: 'border-red-600 bg-red-50 dark:bg-red-950',
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newToast: Toast = { ...toast, id };
setToasts(prev => [...prev, newToast]);
// Auto dismiss
if (toast.duration !== 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, toast.duration || 4000);
}
}, []);
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
return () => {
globalShowToast = null;
};
}, [showToast]);
return (
<ToastContext.Provider value={{ toasts, showToast, dismissToast }}>
{children}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
);
};
const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => void }> = ({ toasts, onDismiss }) => {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
TOAST_STYLES[t.type]
)}
>
<div className="flex-shrink-0 mt-0.5">
{TOAST_ICONS[t.type]}
</div>
<div className="flex-1 min-w-0">
{t.title && (
<div className="text-sm font-medium text-foreground">{t.title}</div>
)}
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
</div>
<button
onClick={() => onDismiss(t.id)}
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
))}
</div>
);
};
export default ToastProvider;