From 57358dfd211168198e1b9f7c90ad69bcc6a3f819 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:28:45 -0600 Subject: [PATCH] feat: add Toast notification system - ToastProvider context with useToast hook - Supports success, error, info, and warning variants - Auto-dismiss with configurable duration (default 4s) - Slide-in animation with progress bar - Stacks up to 5 toasts, oldest dismissed first - Consistent with dark theme UI --- client/src/components/ToastProvider.jsx | 145 ++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 client/src/components/ToastProvider.jsx diff --git a/client/src/components/ToastProvider.jsx b/client/src/components/ToastProvider.jsx new file mode 100644 index 0000000..193ff47 --- /dev/null +++ b/client/src/components/ToastProvider.jsx @@ -0,0 +1,145 @@ +import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'; + +const ToastContext = createContext(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within a ToastProvider'); + return ctx; +} + +const VARIANTS = { + success: { bg: '#053321', border: '#0f5132', color: '#9ef7c1', icon: '✓' }, + error: { bg: '#3c1114', border: '#f5c6cb', color: '#ffb3b8', icon: '✗' }, + info: { bg: '#0c1f3f', border: '#2563eb', color: '#93c5fd', icon: 'ℹ' }, + warning: { bg: '#3b2e00', border: '#d4af37', color: '#ffdf8a', icon: '⚠' }, +}; + +let nextId = 0; + +function Toast({ toast, onDismiss }) { + const v = VARIANTS[toast.variant] || VARIANTS.info; + const [exiting, setExiting] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + timerRef.current = setTimeout(() => { + setExiting(true); + setTimeout(() => onDismiss(toast.id), 280); + }, toast.duration || 4000); + return () => clearTimeout(timerRef.current); + }, [toast.id, toast.duration, onDismiss]); + + const handleDismiss = () => { + clearTimeout(timerRef.current); + setExiting(true); + setTimeout(() => onDismiss(toast.id), 280); + }; + + return ( +
+ {v.icon} + {toast.message} + +
+
+ ); +} + +export default function ToastProvider({ children }) { + const [toasts, setToasts] = useState([]); + + const dismiss = useCallback((id) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + + const addToast = useCallback((message, variant = 'info', duration = 4000) => { + const id = ++nextId; + setToasts(prev => { + const next = [...prev, { id, message, variant, duration }]; + return next.length > 5 ? next.slice(-5) : next; + }); + return id; + }, []); + + const toast = useCallback({ + success: (msg, dur) => addToast(msg, 'success', dur), + error: (msg, dur) => addToast(msg, 'error', dur || 6000), + info: (msg, dur) => addToast(msg, 'info', dur), + warning: (msg, dur) => addToast(msg, 'warning', dur || 5000), + }, [addToast]); + + // Inject keyframes once + useEffect(() => { + if (document.getElementById('toast-keyframes')) return; + const style = document.createElement('style'); + style.id = 'toast-keyframes'; + style.textContent = ` + @keyframes toastIn { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } + } + @keyframes toastOut { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(100%); } + } + @keyframes toastProgress { + from { width: 100%; } + to { width: 0%; } + } + `; + document.head.appendChild(style); + }, []); + + return ( + + {children} +
+ {toasts.map(t => ( +
+ +
+ ))} +
+
+ ); +}