// Toast notification system for backend errors // Displays toast notifications at the top of the page type ToastType = 'error' | 'success' | 'info'; const TOAST_DEFAULT_DURATION = 5000; // Create toast container const createToastContainer = (): HTMLElement => { let container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; container.className = 'fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none'; document.body.appendChild(container); } return container; }; // Escape HTML to prevent XSS const escapeHtml = (text: string): string => { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }; // Get toast configuration by type const getToastConfig = (type: ToastType) => { const configs = { error: { bgClass: 'bg-red-500/90', icon: '❌' }, success: { bgClass: 'bg-green-500/90', icon: '✅' }, info: { bgClass: 'bg-blue-500/90', icon: 'ℹ️' } }; return configs[type]; }; // Create a toast element const createToastElement = (message: string, type: ToastType): HTMLElement => { const toast = document.createElement('div'); const config = getToastConfig(type); toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`; toast.innerHTML = ` ${config.icon} ${escapeHtml(message)} `; // Add close button handler const closeBtn = toast.querySelector('.toast-close') as HTMLElement; if (closeBtn) { closeBtn.onclick = () => removeToast(toast); } return toast; }; // Trigger toast animation const animateToastIn = (toast: HTMLElement): void => { setTimeout(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; }, 10); }; // Remove toast with animation const removeToast = (toast: HTMLElement): void => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-20px)'; setTimeout(() => { if (toast.parentElement) { toast.parentElement.removeChild(toast); } }, 300); }; // Show toast notification const showToast = (message: string, type: ToastType, duration: number = TOAST_DEFAULT_DURATION): void => { const container = createToastContainer(); const toast = createToastElement(message, type); container.appendChild(toast); animateToastIn(toast); // Auto-remove after duration setTimeout(() => { removeToast(toast); }, duration); }; // Parse error from XHR response const parseXHRError = (xhr: XMLHttpRequest): string => { let errorMessage = 'An error occurred'; try { const response = JSON.parse(xhr.responseText); errorMessage = response.error || response.message || errorMessage; } catch (e) { errorMessage = xhr.responseText || errorMessage; } return errorMessage; }; // Parse error from fetch response const parseFetchError = async (response: Response): Promise => { const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { const data = await response.json(); return data.error || data.message || `Error ${response.status}`; } return `Error ${response.status}: ${response.statusText}`; }; // Setup HTMX error listeners const setupHTMXListeners = (): void => { // Listen for HTMX afterSwap event to detect errors in swapped content document.body.addEventListener('htmx:afterSwap', (evt: Event) => { interface HTMXEventDetail { xhr: XMLHttpRequest; succeeded: boolean; target: Element; } const customEvent = evt as CustomEvent; // Check if request failed if (customEvent.detail.succeeded === false && customEvent.detail.xhr) { const xhr = customEvent.detail.xhr; // Show toast for HTTP errors if (xhr.status >= 400 && xhr.status < 600) { const errorMessage = parseXHRError(xhr); showToast(errorMessage, 'error'); } } }); // Also listen for response errors (network issues, invalid responses) document.body.addEventListener('htmx:responseError', (evt: Event) => { const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>; const xhr = customEvent.detail.xhr; const errorMessage = parseXHRError(xhr); showToast(errorMessage, 'error'); }); }; // Setup fetch interceptor const setupFetchInterceptor = (): void => { const originalFetch = window.fetch; window.fetch = async (...args: Parameters): Promise => { try { const response = await originalFetch(...args); if (!response.ok) { const errorMessage = await parseFetchError(response); showToast(errorMessage, 'error'); } return response; } catch (error) { showToast('Network error: Unable to connect to server', 'error'); throw error; } }; }; // Initialize toast system const initializeToastSystem = (): void => { setupHTMXListeners(); setupFetchInterceptor(); }; // Auto-initialize when DOM is ready if (typeof document !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeToastSystem); } else { initializeToastSystem(); } } // Export toast API for manual use (window as any).showToast = { error: (message: string, duration?: number) => showToast(message, 'error', duration), success: (message: string, duration?: number) => showToast(message, 'success', duration), info: (message: string, duration?: number) => showToast(message, 'info', duration) };