Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions frontend/src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";

type ToastType = "success" | "error" | "info" | "warning";

interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number; // ms, 0 = sticky
}

interface ToastContextValue {
addToast: (type: ToastType, message: string, duration?: number) => void;
removeToast: (id: string) => void;
}

const ToastContext = createContext<ToastContextValue>({
addToast: () => {},
removeToast: () => {},
});

export function useToast() {
return useContext(ToastContext);
}

const ICONS: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5 text-emerald" />,
error: <AlertCircle className="w-5 h-5 text-status-error" />,
info: <Info className="w-5 h-5 text-status-info" />,
warning: <AlertTriangle className="w-5 h-5 text-status-warning" />,
};

const BORDER_COLORS: Record<ToastType, string> = {
success: "border-emerald-border",
error: "border-status-error/30",
info: "border-status-info/30",
warning: "border-status-warning/30",
};

const DEFAULT_DURATION = 5000; // 5 seconds

function ToastItem({
toast,
onRemove,
}: {
toast: Toast;
onRemove: (id: string) => void;
}) {
useEffect(() => {
if (toast.duration === 0) return; // sticky
const timer = setTimeout(() => onRemove(toast.id), toast.duration ?? DEFAULT_DURATION);
return () => clearTimeout(timer);
}, [toast, onRemove]);

return (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={`flex items-start gap-3 px-4 py-3 rounded-xl border bg-forge-900/95 backdrop-blur-md shadow-xl shadow-black/30 min-w-[300px] max-w-sm ${BORDER_COLORS[toast.type]}`}
>
<span className="flex-shrink-0 mt-0.5">{ICONS[toast.type]}</span>
<p className="flex-1 text-sm text-text-primary leading-snug">{toast.message}</p>
<button
onClick={() => onRemove(toast.id)}
className="flex-shrink-0 p-0.5 rounded hover:bg-forge-800 text-text-muted hover:text-text-primary transition-colors"
>
<X className="w-4 h-4" />
</button>
</motion.div>
);
}

export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);

const addToast = useCallback(
(type: ToastType, message: string, duration?: number) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => [...prev.slice(-4), { id, type, message, duration }]);
},
[]
);

const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

return (
<ToastContext.Provider value={{ addToast, removeToast }}>
{children}
{/* Toast container — fixed bottom-right */}
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-2 pointer-events-none">
<AnimatePresence>
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onRemove={removeToast} />
</div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
9 changes: 6 additions & 3 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './components/ui/Toast';
import { queryClient } from './services/queryClient';
import App from './App';
import './index.css';
Expand All @@ -14,9 +15,11 @@ createRoot(root).render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
</AuthProvider>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
</QueryClientProvider>
</BrowserRouter>
</StrictMode>
Expand Down