// Toast notification system for backend errors // Displays toast notifications at the top of the page type ToastType = "error" | "success" | "info" | "warning"; 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: "ℹ️", }, warning: { bgClass: "bg-yellow-500/90", icon: "⚠️", }, }; return configs[type]; }; // Create a toast element const createToastElement = ( message: string, type: ToastType, action?: { label: string; onClick: () => void }, ): 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`; const actionHtml = action ? `` : ""; toast.innerHTML = ` ${config.icon} ${toastEscapeHtml(message)} ${actionHtml} `; // Add close button handler const closeBtn = toast.querySelector(".toast-close") as HTMLElement; if (closeBtn) { closeBtn.onclick = () => removeToast(toast); } // Action button: run the handler and dismiss (an action consumes the // toast — leaving it up after acting invites double-fires). const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null; if (actionBtn && action) { actionBtn.onclick = () => { removeToast(toast); action.onClick(); }; } 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. `action` (optional): a labeled button for // one-click follow-ups (e.g. the sleep timer's "Resume"); acting // dismisses the toast. const showToast = ( message: string, type: ToastType, duration: number = TOAST_DEFAULT_DURATION, action?: { label: string; onClick: () => void }, ): void => { const container = createToastContainer(); const toast = createToastElement(message, type, action); 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 { 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; if (customEvent.detail.xhr) { const xhr = customEvent.detail.xhr; if (customEvent.detail.succeeded === false && xhr.status >= 400 && xhr.status < 600) { const errorMessage = parseXHRError(xhr); showToast(errorMessage, "error"); } else if (xhr.status >= 200 && xhr.status < 300) { const contentType = xhr.getResponseHeader("content-type"); if (contentType && contentType.includes("application/json")) { try { const response = JSON.parse(xhr.responseText); if (response.message) { showToast(response.message, "success"); if (customEvent.detail.target) { customEvent.detail.target.innerHTML = ""; } } } catch { // Not valid JSON — ignore } } } } }); // 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"); }); }; // Suggestion fetches — field-value autocomplete on the search endpoint // (author=, genre=, note_text=, collection=, … any filter-registry // field key) — are background hints for typed inputs. A failed hint // must never interrupt the user with a toast: it logs to the console // only. The grid's real search (q=/sort= params) is NOT a suggestion // fetch and keeps its toasts. const suggestionFieldRe = /[?&](author|narrator|contributor|publisher|imprint|edition|genre|tags|story_arc|age_rating|title|subtitle|language|format_group|mime_type|manga_type|reading_direction|series|note_text|highlight_text|collection|library|added_by)=/; function isSuggestionFetch(input: Parameters[0]): boolean { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; // The builder's live-count endpoint is a background hint too — a // failed count must never interrupt with a toast. if ( !url.includes("/api/media-items/search?") && !url.includes("/api/media-items/filter-count?") ) { return false; } if (url.includes("/api/media-items/filter-count?")) return true; return suggestionFieldRe.test(url) && !/[?&](q|sort)=/.test(url); } // 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. Suggestion fetches are exempt (console // only) — a failed background hint never interrupts. if (!response.ok) { const errorMessage = await parseFetchError(response); if (isSuggestionFetch(args[0])) { console.warn(`Suggestion fetch failed (${response.status}): ${errorMessage}`); } else { showToast(errorMessage, "error"); } } return response; } catch (error) { // Don't show toast for redirect errors if ((error as Error).message !== "Session expired") { if (isSuggestionFetch(args[0])) { console.warn("Suggestion fetch failed: network error", error); } else { showToast("Network error: Unable to connect to server", "error"); } } throw error; } }; }; // Initialize toast system const initializeToastSystem = (): void => { setupHTMXListeners(); setupFetchInterceptor(); }; initializeToastSystem(); export { showToast, getToastConfig }; export type { ToastType };