// 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 toastEscapeHtml = (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} ${toastEscapeHtml(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); // Special handling for 401 Unauthorized if (response.status === 401) { // Clear invalid tokens from localStorage localStorage.removeItem("token"); localStorage.removeItem("refreshToken"); localStorage.removeItem("user"); // Check if this was a page navigation (not API call) const url = args[0] as string; // Don't show toast for page navigations - will be handled by redirect if (!url.startsWith("/api/")) { // Direct navigation to protected page will be caught by middleware // Just throw to prevent further processing throw new Error("Session expired"); } // API call - show toast error const errorMessage = await parseFetchError(response); showToast(errorMessage, "error"); return response; } // Handle other errors if (!response.ok) { const errorMessage = await parseFetchError(response); showToast(errorMessage, "error"); } return response; } catch (error) { // Don't show toast for redirect errors if ((error as Error).message !== "Session expired") { showToast("Network error: Unable to connect to server", "error"); } throw error; } }; }; // Initialize toast system const initializeToastSystem = (): void => { setupHTMXListeners(); setupFetchInterceptor(); }; initializeToastSystem(); export { showToast }; export type { ToastType };