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
78 changes: 78 additions & 0 deletions frontend/src/components/bounty/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import type { Toast } from './ToastContext';

const ICONS: Record<string, string> = {
success: '✓',
error: '✕',
warning: '⚠',
info: 'ℹ',
};

const STYLES: Record<string, string> = {
success: 'bg-emerald-50 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-700 text-emerald-800 dark:text-emerald-200',
error: 'bg-red-50 dark:bg-red-900/40 border-red-200 dark:border-red-700 text-red-800 dark:text-red-200',
warning: 'bg-amber-50 dark:bg-amber-900/40 border-amber-200 dark:border-amber-700 text-amber-800 dark:text-amber-200',
info: 'bg-blue-50 dark:bg-blue-900/40 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200',
};

const ICON_BG: Record<string, string> = {
success: 'bg-emerald-500',
error: 'bg-red-500',
warning: 'bg-amber-500',
info: 'bg-blue-500',
};

interface ToastItemProps {
toast: Toast;
onDismiss: (id: string) => void;
}

function ToastItem({ toast, onDismiss }: ToastItemProps) {
const [visible, setVisible] = useState(false);

useEffect(() => {
const timer = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(timer);
}, []);

return (
<div
role="alert"
className={`
flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg
transition-all duration-300 ease-out max-w-sm w-full
${STYLES[toast.type] || STYLES.info}
${visible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
`}
>
<span className={`
flex items-center justify-center w-5 h-5 rounded-full text-white text-xs font-bold shrink-0 mt-0.5
${ICON_BG[toast.type] || ICON_BG.info}
`}>
{ICONS[toast.type] || ICONS.info}
</span>
<p className="flex-1 text-sm leading-5">{toast.message}</p>
<button
onClick={() => onDismiss(toast.id)}
className="shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
</button>
</div>
);
}

export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
if (toasts.length === 0) return null;

return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onDismiss={onDismiss} />
</div>
))}
</div>
);
}
57 changes: 57 additions & 0 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';

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

interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}

interface ToastContextValue {
toasts: Toast[];
addToast: (type: ToastType, message: string, duration?: number) => string;
removeToast: (id: string) => void;
success: (msg: string) => string;
error: (msg: string) => string;
warning: (msg: string) => string;
info: (msg: string) => string;
}

const ToastContext = createContext<ToastContextValue | null>(null);

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

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

const addToast = useCallback((type: ToastType, message: string, duration = 5000): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => [...prev, { id, type, message, duration }]);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}, [removeToast]);

const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
const warning = useCallback((msg: string) => addToast('warning', msg), [addToast]);
const info = useCallback((msg: string) => addToast('info', msg), [addToast]);

return (
<ToastContext.Provider value={{ toasts, addToast, removeToast, success, error, warning, info }}>
{children}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</ToastContext.Provider>
);
}

export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
2 changes: 2 additions & 0 deletions frontend/src/contexts/toast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ToastProvider, useToast } from './ToastContext';
export type { Toast } from './ToastContext';