diff --git a/frontend/src/components/ui/toast-store.ts b/frontend/src/components/ui/toast-store.ts index e0c66328..195dd341 100644 --- a/frontend/src/components/ui/toast-store.ts +++ b/frontend/src/components/ui/toast-store.ts @@ -2,13 +2,27 @@ import { useSyncExternalStore } from 'react'; export type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading'; +export interface ToastAction { + label: string; + onClick: () => void; +} + +export interface ToastOptions { + action?: ToastAction; + duration?: number; +} + export interface Toast { id: string; type: ToastType; message: string; createdAt: number; + action?: ToastAction; + duration?: number; } +const MAX_BUFFERED = 20; + let toasts: Toast[] = []; const listeners: Set<() => void> = new Set(); let idCounter = 0; @@ -17,15 +31,28 @@ function notify() { listeners.forEach((fn) => fn()); } -function addToast(type: ToastType, message: string): string { +function addToast(type: ToastType, message: string, opts?: ToastOptions): string { const id = `toast-${++idCounter}-${Date.now()}`; - toasts = [...toasts, { id, type, message, createdAt: Date.now() }]; + const next: Toast[] = [ + ...toasts, + { + id, + type, + message, + createdAt: Date.now(), + action: opts?.action, + duration: opts?.duration, + }, + ]; + toasts = next.length > MAX_BUFFERED ? next.slice(-MAX_BUFFERED) : next; notify(); return id; } export function removeToast(id: string) { - toasts = toasts.filter((t) => t.id !== id); + const next = toasts.filter((t) => t.id !== id); + if (next.length === toasts.length) return; + toasts = next; notify(); } @@ -45,10 +72,11 @@ export function useToasts() { } export const toast = { - success: (message: string) => addToast('success', message), - error: (message: string) => addToast('error', message), - warning: (message: string) => addToast('warning', message), - info: (message: string) => addToast('info', message), - loading: (message: string) => addToast('loading', message), + success: (message: string, opts?: ToastOptions) => addToast('success', message, opts), + error: (message: string, opts?: ToastOptions) => addToast('error', message, opts), + warning: (message: string, opts?: ToastOptions) => addToast('warning', message, opts), + info: (message: string, opts?: ToastOptions) => addToast('info', message, opts), + loading: (message: string, opts?: Omit) => + addToast('loading', message, opts), dismiss: (id: string) => removeToast(id), }; diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx index 1ae40159..c37978a1 100644 --- a/frontend/src/components/ui/toast.tsx +++ b/frontend/src/components/ui/toast.tsx @@ -1,12 +1,24 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { memo, useEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'motion/react'; -import { Loader2 } from 'lucide-react'; -import { useToasts, removeToast, type ToastType } from './toast-store'; +import { + CheckCircle2, + XCircle, + AlertTriangle, + Info, + Loader2, + X, + type LucideIcon, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + useToasts, + removeToast, + type Toast, + type ToastType, +} from './toast-store'; -/* ── Durations & Config ── */ - -const DURATIONS: Record = { +const DEFAULT_DURATIONS: Record = { success: 4000, error: 6000, warning: 5000, @@ -16,83 +28,81 @@ const DURATIONS: Record = { const MAX_VISIBLE = 5; -/* ── SVG Icons (matching Sera UI exactly — h-6 w-6, stroke-based) ── */ +type ToastConfig = { + icon: LucideIcon; + iconClass: string; + railClass: string; + progressClass: string; + kicker: string; + spin?: boolean; +}; -const InfoIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const SuccessIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const WarningIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const ErrorIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -const CloseIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -/* ── Type config (matching Sera UI's notificationConfig) ── */ - -const notificationConfig: Record = { - info: { - iconColor: 'text-info', - icon: , - }, +const TOAST_CONFIG: Record = { success: { - iconColor: 'text-success', - icon: , - }, - warning: { - iconColor: 'text-warning', - icon: , + icon: CheckCircle2, + iconClass: 'text-success', + railClass: 'bg-success', + progressClass: 'bg-success/50', + kicker: 'Success', }, error: { - iconColor: 'text-destructive', - icon: , + icon: XCircle, + iconClass: 'text-destructive', + railClass: 'bg-destructive', + progressClass: 'bg-destructive/50', + kicker: 'Error', + }, + warning: { + icon: AlertTriangle, + iconClass: 'text-warning', + railClass: 'bg-warning', + progressClass: 'bg-warning/50', + kicker: 'Warning', + }, + info: { + icon: Info, + iconClass: 'text-info', + railClass: 'bg-info', + progressClass: 'bg-info/50', + kicker: 'Info', }, loading: { - iconColor: 'text-brand', - icon: , + icon: Loader2, + iconClass: 'text-brand', + railClass: 'bg-brand', + progressClass: 'bg-brand/50', + kicker: 'Working', + spin: true, }, }; -/* ── ToastItem — faithful Sera UI Notification replica ── */ +const ToastItem = memo(function ToastItem({ + type, + message, + action, + duration: explicitDuration, + id, +}: Toast) { + const config = TOAST_CONFIG[type]; + const Icon = config.icon; + const duration = explicitDuration ?? DEFAULT_DURATIONS[type]; -function ToastItem({ id, type, message }: { id: string; type: ToastType; message: string }) { const [hovered, setHovered] = useState(false); const timerRef = useRef | null>(null); - const remainingRef = useRef(DURATIONS[type]); + const remainingRef = useRef(duration); const startRef = useRef(0); - const config = notificationConfig[type]; - const duration = DURATIONS[type]; const dismiss = useCallback(() => { removeToast(id); }, [id]); - // Auto-dismiss timer with hover pause (loading toasts never auto-dismiss) + const handleAction = useCallback(() => { + action?.onClick(); + removeToast(id); + }, [action, id]); + useEffect(() => { - if (type === 'loading') return; + if (type === 'loading' || !Number.isFinite(duration)) return; if (hovered) { if (timerRef.current) { clearTimeout(timerRef.current); @@ -106,7 +116,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - }, [hovered, dismiss, type]); + }, [hovered, dismiss, type, duration]); return ( setHovered(true)} onMouseLeave={() => setHovered(false)} + role={type === 'error' ? 'alert' : 'status'} + aria-atomic="true" > -
-
- {config.icon} -
-
-

{message}

+
+ +
+ +
+
+ {config.kicker} +
+

+ {message} +

+ {action ? ( + + ) : null}
- {/* Progress bar */} -
+
{type === 'loading' ? ( - ) : ( + ) : Number.isFinite(duration) ? ( - )} + ) : null}
); -} - -/* ── ToastContainer ── */ +}); export function ToastContainer() { const toasts = useToasts(); const visible = toasts.slice(-MAX_VISIBLE); return createPortal( -
+
{visible.map((t) => ( - + ))}
, - document.body + document.body, ); }