91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import * as React from 'react';
|
||
|
|
import { X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
||
|
|
import { cn } from '@/lib/utils/cn';
|
||
|
|
|
||
|
|
export type ToastType = 'success' | 'error' | 'info';
|
||
|
|
|
||
|
|
export interface Toast {
|
||
|
|
id: string;
|
||
|
|
message: string;
|
||
|
|
type: ToastType;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ToastContextType {
|
||
|
|
toasts: Toast[];
|
||
|
|
addToast: (message: string, type?: ToastType) => void;
|
||
|
|
removeToast: (id: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ToastContext = React.createContext<ToastContextType | undefined>(undefined);
|
||
|
|
|
||
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||
|
|
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||
|
|
|
||
|
|
const addToast = React.useCallback((message: string, type: ToastType = 'success') => {
|
||
|
|
const id = Math.random().toString(36).substring(7);
|
||
|
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||
|
|
|
||
|
|
// Auto remove after 3 seconds
|
||
|
|
setTimeout(() => {
|
||
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||
|
|
}, 3000);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const removeToast = React.useCallback((id: string) => {
|
||
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||
|
|
{children}
|
||
|
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||
|
|
{toasts.map((toast) => (
|
||
|
|
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ToastContext.Provider>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||
|
|
const Icon = toast.type === 'success' ? CheckCircle2 : toast.type === 'error' ? AlertCircle : Info;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg pointer-events-auto',
|
||
|
|
'animate-in slide-in-from-right-full duration-300',
|
||
|
|
'min-w-[300px] max-w-[400px]',
|
||
|
|
{
|
||
|
|
'bg-green-50 text-green-900 border border-green-200 dark:bg-green-900/20 dark:text-green-100 dark:border-green-800':
|
||
|
|
toast.type === 'success',
|
||
|
|
'bg-red-50 text-red-900 border border-red-200 dark:bg-red-900/20 dark:text-red-100 dark:border-red-800':
|
||
|
|
toast.type === 'error',
|
||
|
|
'bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-900/20 dark:text-blue-100 dark:border-blue-800':
|
||
|
|
toast.type === 'info',
|
||
|
|
}
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Icon className="h-5 w-5 flex-shrink-0" />
|
||
|
|
<p className="text-sm font-medium flex-1">{toast.message}</p>
|
||
|
|
<button
|
||
|
|
onClick={onClose}
|
||
|
|
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
|
||
|
|
aria-label="Close"
|
||
|
|
>
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useToast() {
|
||
|
|
const context = React.useContext(ToastContext);
|
||
|
|
if (!context) {
|
||
|
|
throw new Error('useToast must be used within ToastProvider');
|
||
|
|
}
|
||
|
|
return context;
|
||
|
|
}
|