Files
bookhoard/web/src/toast.ts
T
john-okeefe a8920a8f6c Add dashboard redesign, custom section builder, and enhanced search functionality
Features:
- Complete dashboard redesign with improved UI components and layout
- Implement custom section builder for personalized book organization
- Add new events tracking system for user interactions
- Enhance search functionality with better static search.js
- Update TypeScript type definitions for API responses

Backend:
- Update Go dependencies in go.mod
- Add new frontend routes in router

Templates:
- Update admin and dashboard templates with new components

Frontend:
- Refactor analytics, collections, conflicts, and queue modules
- Add new documentation features in docs.ts
- Implement linking between books and collections
- Add toast notifications for user feedback
- Include placeholder book SVG asset

This commit consolidates multiple feature additions and improvements
across the entire stack including backend, templates, and frontend.
2026-02-25 16:56:10 -05:00

226 lines
7.6 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();
};
// 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)
};