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.
226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
// 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)
|
||
};
|