212 lines
7.1 KiB
TypeScript
Executable File
212 lines
7.1 KiB
TypeScript
Executable File
import React, { useEffect, useState } from 'react';
|
|
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
|
|
|
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
|
|
export interface ToastMessage {
|
|
id: string;
|
|
type: ToastType;
|
|
title: string;
|
|
message?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
interface ToastItemProps {
|
|
toast: ToastMessage;
|
|
onRemove: (id: string) => void;
|
|
}
|
|
|
|
const ToastItem: React.FC<ToastItemProps> = ({ toast, onRemove }) => {
|
|
const [isExiting, setIsExiting] = useState(false);
|
|
const [progress, setProgress] = useState(100);
|
|
|
|
const duration = toast.duration || 5000;
|
|
|
|
useEffect(() => {
|
|
// Progress bar animation
|
|
const interval = setInterval(() => {
|
|
setProgress((prev) => {
|
|
const newProgress = prev - (100 / (duration / 50));
|
|
return newProgress <= 0 ? 0 : newProgress;
|
|
});
|
|
}, 50);
|
|
|
|
// Auto dismiss
|
|
const timer = setTimeout(() => {
|
|
handleClose();
|
|
}, duration);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
clearTimeout(timer);
|
|
};
|
|
}, [duration]);
|
|
|
|
const handleClose = () => {
|
|
setIsExiting(true);
|
|
setTimeout(() => {
|
|
onRemove(toast.id);
|
|
}, 300);
|
|
};
|
|
|
|
const getIcon = () => {
|
|
switch (toast.type) {
|
|
case 'success':
|
|
return <CheckCircle className="h-6 w-6 text-emerald-500" />;
|
|
case 'error':
|
|
return <XCircle className="h-6 w-6 text-red-500" />;
|
|
case 'warning':
|
|
return <AlertCircle className="h-6 w-6 text-amber-500" />;
|
|
case 'info':
|
|
return <Info className="h-6 w-6 text-blue-500" />;
|
|
}
|
|
};
|
|
|
|
const getStyles = () => {
|
|
const baseStyles = 'relative overflow-hidden backdrop-blur-xl border shadow-2xl';
|
|
switch (toast.type) {
|
|
case 'success':
|
|
return `${baseStyles} bg-gradient-to-r from-emerald-50/95 to-green-50/95 border-emerald-200/50`;
|
|
case 'error':
|
|
return `${baseStyles} bg-gradient-to-r from-red-50/95 to-rose-50/95 border-red-200/50`;
|
|
case 'warning':
|
|
return `${baseStyles} bg-gradient-to-r from-amber-50/95 to-yellow-50/95 border-amber-200/50`;
|
|
case 'info':
|
|
return `${baseStyles} bg-gradient-to-r from-blue-50/95 to-indigo-50/95 border-blue-200/50`;
|
|
}
|
|
};
|
|
|
|
const getProgressColor = () => {
|
|
switch (toast.type) {
|
|
case 'success':
|
|
return 'bg-gradient-to-r from-emerald-400 to-green-500';
|
|
case 'error':
|
|
return 'bg-gradient-to-r from-red-400 to-rose-500';
|
|
case 'warning':
|
|
return 'bg-gradient-to-r from-amber-400 to-yellow-500';
|
|
case 'info':
|
|
return 'bg-gradient-to-r from-blue-400 to-indigo-500';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
${getStyles()}
|
|
rounded-2xl p-4 w-full max-w-sm
|
|
transform transition-all duration-300 ease-out
|
|
${isExiting ? 'translate-x-full opacity-0 scale-95' : 'translate-x-0 opacity-100 scale-100'}
|
|
animate-slide-in-right
|
|
`}
|
|
role="alert"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
{getIcon()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-bold text-slate-800 text-sm">{toast.title}</p>
|
|
{toast.message && (
|
|
<p className="text-slate-600 text-xs mt-0.5 leading-relaxed">{toast.message}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
className="flex-shrink-0 p-1 rounded-lg hover:bg-black/5 transition-colors"
|
|
>
|
|
<X className="h-4 w-4 text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/5 overflow-hidden rounded-b-2xl">
|
|
<div
|
|
className={`h-full ${getProgressColor()} transition-all duration-50 ease-linear`}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ToastContainerProps {
|
|
toasts: ToastMessage[];
|
|
onRemove: (id: string) => void;
|
|
}
|
|
|
|
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onRemove }) => {
|
|
return (
|
|
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-3 pointer-events-none">
|
|
{toasts.map((toast) => (
|
|
<div key={toast.id} className="pointer-events-auto">
|
|
<ToastItem toast={toast} onRemove={onRemove} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Toast Hook
|
|
export const useToast = () => {
|
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
|
|
|
const addToast = (type: ToastType, title: string, message?: string, duration?: number) => {
|
|
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
const newToast: ToastMessage = { id, type, title, message, duration };
|
|
setToasts((prev) => [...prev, newToast]);
|
|
return id;
|
|
};
|
|
|
|
const removeToast = (id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
};
|
|
|
|
const toast = {
|
|
success: (title: string, message?: string, duration?: number) => addToast('success', title, message, duration),
|
|
error: (title: string, message?: string, duration?: number) => addToast('error', title, message, duration),
|
|
warning: (title: string, message?: string, duration?: number) => addToast('warning', title, message, duration),
|
|
info: (title: string, message?: string, duration?: number) => addToast('info', title, message, duration),
|
|
};
|
|
|
|
return { toasts, toast, removeToast };
|
|
};
|
|
|
|
// Global Toast Context
|
|
import { createContext, useContext } from 'react';
|
|
|
|
interface ToastContextType {
|
|
toast: {
|
|
success: (title: string, message?: string, duration?: number) => string;
|
|
error: (title: string, message?: string, duration?: number) => string;
|
|
warning: (title: string, message?: string, duration?: number) => string;
|
|
info: (title: string, message?: string, duration?: number) => string;
|
|
};
|
|
}
|
|
|
|
export const ToastContext = createContext<ToastContextType | null>(null);
|
|
|
|
export const useToastContext = () => {
|
|
const context = useContext(ToastContext);
|
|
if (!context) {
|
|
throw new Error('useToastContext must be used within a ToastProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
interface ToastProviderProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
|
const { toasts, toast, removeToast } = useToast();
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ toast }}>
|
|
{children}
|
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
|
</ToastContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default ToastItem;
|