// 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 };