70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { CheckCircleIcon, ExclamationCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
type: 'success' | 'error';
|
|
message: string;
|
|
}
|
|
|
|
let toastListeners: ((toast: Toast) => void)[] = [];
|
|
|
|
export function showToast(type: 'success' | 'error', message: string) {
|
|
const toast: Toast = {
|
|
id: Math.random().toString(36).slice(2),
|
|
type,
|
|
message,
|
|
};
|
|
toastListeners.forEach((fn) => fn(toast));
|
|
}
|
|
|
|
export default function ToastContainer() {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
const addToast = useCallback((toast: Toast) => {
|
|
setToasts((prev) => [...prev, toast]);
|
|
setTimeout(() => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
|
|
}, 4000);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
toastListeners.push(addToast);
|
|
return () => {
|
|
toastListeners = toastListeners.filter((fn) => fn !== addToast);
|
|
};
|
|
}, [addToast]);
|
|
|
|
if (toasts.length === 0) return null;
|
|
|
|
return (
|
|
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 w-full max-w-md pointer-events-none">
|
|
{toasts.map((toast) => (
|
|
<div
|
|
key={toast.id}
|
|
className={`pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl ring-1 animate-in fade-in slide-in-from-top-4 duration-300 ${
|
|
toast.type === 'success'
|
|
? 'bg-green-50 dark:bg-green-950/80 ring-green-200 dark:ring-green-800 text-green-800 dark:text-green-200'
|
|
: 'bg-red-50 dark:bg-red-950/80 ring-red-200 dark:ring-red-800 text-red-800 dark:text-red-200'
|
|
}`}
|
|
>
|
|
{toast.type === 'success' ? (
|
|
<CheckCircleIcon className="h-5 w-5 text-green-500 dark:text-green-400 flex-shrink-0" />
|
|
) : (
|
|
<ExclamationCircleIcon className="h-5 w-5 text-red-500 dark:text-red-400 flex-shrink-0" />
|
|
)}
|
|
<p className="flex-1 text-sm font-medium">{toast.message}</p>
|
|
<button
|
|
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
|
className="flex-shrink-0 p-0.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<XMarkIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|