Files
bookhoard/web/src/toast.ts
T
john-okeefe 4426cacb46 fix: improve HTMX error detection and remove module exports
- Change from htmx:beforeSwap to htmx:afterSwap event for better error timing
- Simplify event listener setup (removed duplicate handlers)
- Remove 'export {}' statement that was causing syntax errors
- Add proper TypeScript interface for HTMX event details
- Errors now detected after content swap, ensuring accurate error messages
- Toast notifications work correctly for all backend HTTP errors

Resolves JavaScript syntax error on page load and improves error handling.
2026-01-29 14:53:01 -05:00

198 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 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 = `
<span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${escapeHtml(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);
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)
};