98 lines
2.8 KiB
TypeScript
98 lines
2.8 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { clsx } from 'clsx';
|
|
import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from 'lucide-react';
|
|
|
|
export interface ToastMessage {
|
|
id: string;
|
|
type: 'success' | 'error' | 'warning' | 'info';
|
|
title: string;
|
|
description?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
type ToastListener = (toasts: ToastMessage[]) => void;
|
|
let globalToasts: ToastMessage[] = [];
|
|
const listeners = new Set<ToastListener>();
|
|
function notifyListeners() {
|
|
listeners.forEach(l => l([...globalToasts]));
|
|
}
|
|
|
|
export function toast(msg: Omit<ToastMessage, 'id'>) {
|
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
globalToasts = [...globalToasts, { ...msg, id }];
|
|
notifyListeners();
|
|
const duration = msg.duration ?? 5000;
|
|
if (duration > 0) setTimeout(() => dismissToast(id), duration);
|
|
}
|
|
|
|
export function dismissToast(id: string) {
|
|
globalToasts = globalToasts.filter(t => t.id !== id);
|
|
notifyListeners();
|
|
}
|
|
|
|
export function useToast() {
|
|
return { toast, dismiss: dismissToast };
|
|
}
|
|
|
|
const icons: Record<string, React.ReactNode> = {
|
|
success: <CheckCircle className="h-5 w-5 text-green-400" />,
|
|
error: <AlertCircle className="h-5 w-5 text-red-400" />,
|
|
warning: <AlertTriangle className="h-5 w-5 text-yellow-400" />,
|
|
info: <Info className="h-5 w-5 text-blue-400" />,
|
|
};
|
|
|
|
export function Toast({ message, onDismiss }: { message: ToastMessage; onDismiss: () => void }) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className={clsx(
|
|
'flex items-start gap-3 rounded-lg border p-4 shadow-lg backdrop-blur-sm',
|
|
'bg-[var(--bl-surface-card,#1a1a2e)] border-[var(--bl-border,#2a2a4a)] text-[var(--bl-text-primary,#fff)]'
|
|
)}
|
|
>
|
|
{icons[message.type]}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium">{message.title}</p>
|
|
{message.description && (
|
|
<p className="mt-1 text-xs text-[var(--bl-text-secondary,#a0a0b0)]">
|
|
{message.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={onDismiss}
|
|
className="text-[var(--bl-text-tertiary,#666)] hover:text-[var(--bl-text-primary,#fff)]"
|
|
aria-label="Dismiss notification"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
|
React.useEffect(() => {
|
|
listeners.add(setToasts);
|
|
return () => {
|
|
listeners.delete(setToasts);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{children}
|
|
<div
|
|
className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm"
|
|
aria-live="polite"
|
|
>
|
|
{toasts.map(t => (
|
|
<Toast key={t.id} message={t} onDismiss={() => dismissToast(t.id)} />
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|