diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 000000000..8680048d3 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,169 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + message: string; + type: ToastVariant; +} + +export interface ToastContextType { + toasts: Toast[]; + showToast: (message: string, type?: ToastVariant) => void; + hideToast: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +// ─── Config ─────────────────────────────────────────────────────────────────── + +const TOAST_DURATION = 5000; + +const variantConfig: Record< + ToastVariant, + { icon: React.ReactNode; bg: string; border: string; iconColor: string; progressColor: string } +> = { + success: { + icon: , + bg: 'bg-emerald-bg', + border: 'border-emerald-border', + iconColor: 'text-emerald', + progressColor: 'bg-emerald', + }, + error: { + icon: , + bg: 'bg-status-error/10', + border: 'border-status-error/30', + iconColor: 'text-status-error', + progressColor: 'bg-status-error', + }, + warning: { + icon: , + bg: 'bg-status-warning/10', + border: 'border-status-warning/30', + iconColor: 'text-status-warning', + progressColor: 'bg-status-warning', + }, + info: { + icon: , + bg: 'bg-status-info/10', + border: 'border-status-info/30', + iconColor: 'text-status-info', + progressColor: 'bg-status-info', + }, +}; + +// ─── Individual Toast Item ───────────────────────────────────────────────────── + +interface ToastItemProps { + toast: Toast; + onDismiss: (id: string) => void; +} + +function ToastItem({ toast, onDismiss }: ToastItemProps) { + const { icon, bg, border, iconColor, progressColor } = variantConfig[toast.type]; + const progressRef = useRef(null); + + useEffect(() => { + const el = progressRef.current; + if (!el) return; + el.style.transition = 'none'; + el.style.width = '100%'; + + requestAnimationFrame(() => { + el.style.transition = `width ${TOAST_DURATION}ms linear`; + el.style.width = '0%'; + }); + + const timer = setTimeout(() => onDismiss(toast.id), TOAST_DURATION); + return () => clearTimeout(timer); + }, [toast.id, onDismiss]); + + return ( + + {/* Progress bar */} +
+
+
+ + {/* Icon */} +
{icon}
+ + {/* Message */} +
+

{toast.message}

+
+ + {/* Close button */} + + + ); +} + +// ─── Toast Container ─────────────────────────────────────────────────────────── + +function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) { + return ( +
+ + {toasts.map((toast) => ( + + ))} + +
+ ); +} + +// ─── Provider & Hook ────────────────────────────────────────────────────────── + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: ToastVariant = 'info') => { + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + setToasts((prev) => [...prev, { id, message, type }]); + }, []); + + const hideToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + + + ); +} + +export function useToast(): ToastContextType { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 000000000..387f72ef6 --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1 @@ +export { useToast } from '../components/Toast';