Files
bookhoard/web/src/toast.ts
T
john-okeefe 2075077bb7 refactor: remove unnecessary DOMContentLoaded wrappers
Since main.js has 'defer' attribute, the DOM is guaranteed to be
ready when modules execute. These wrappers are unnecessary.

dashboard.ts:
- Removed DOMContentLoaded wrapper, code runs directly
- Event delegation setup runs immediately

custom-section-builder.ts:
- Removed DOMContentLoaded wrapper
- initCustomSectionBuilder() called directly

toast.ts:
- Removed DOMContentLoaded wrapper
- initializeToastSystem() called directly at top level
- Removed dead Alpine.data registration (unused)

search.ts:
- Removed DOMContentLoaded wrapper
- initializeSearch exported for use in header

theme.ts:
- Removed DOMContentLoaded wrapper
- Functions now exported for use in header Alpine component
2026-03-13 12:51:16 -04:00

222 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `
<span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
×
</button>
`;
// 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<string> => {
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<HTMXEventDetail>;
// 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<typeof fetch>
): Promise<Response> => {
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 };