feat(web): update frontend TypeScript modules and API types
This commit updates the web frontend TypeScript modules: Core modules: - admin.ts: Admin panel functionality and user management - analytics.ts: Analytics dashboard and data visualization - api-explorer.ts: Interactive API documentation explorer - api.ts: Core API client with request/response handling - collections.ts: Book collection management UI - conflicts.ts: Sync conflict resolution interface - custom-section-builder.ts: Dynamic section builder for UI - docs.ts: Documentation viewer and navigation - dom.ts: DOM manipulation utilities and helpers - header.ts: Application header with navigation - library.ts: Library view and book grid management - linking.ts: Device-book linking interface - password_validation.ts: Client-side password strength validation - queue.ts: Device sync queue management UI - search.ts: Full-text search with Lunr integration - storage.ts: Local storage and cache management - theme.ts: Theme management and CSS variable updates - themeDropdown.ts: Theme selector dropdown component - toast.ts: Toast notification system - woodPaneling.ts: Visual theme effects - woodPanelingInit.ts: Visual effects initialization Type definitions: - api.d.ts: Updated TypeScript definitions for API responses These updates enhance the frontend with improved functionality for book management, device synchronization, and user experience.
This commit is contained in:
+165
-155
@@ -1,225 +1,235 @@
|
||||
// Toast notification system for backend errors
|
||||
// Displays toast notifications at the top of the page
|
||||
|
||||
type ToastType = 'error' | 'success' | 'info';
|
||||
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;
|
||||
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;
|
||||
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];
|
||||
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 = `
|
||||
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;
|
||||
|
||||
// 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);
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
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}`;
|
||||
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;
|
||||
// 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');
|
||||
});
|
||||
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);
|
||||
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');
|
||||
// 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;
|
||||
// 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;
|
||||
// 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();
|
||||
setupHTMXListeners();
|
||||
setupFetchInterceptor();
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeToastSystem);
|
||||
} else {
|
||||
initializeToastSystem();
|
||||
}
|
||||
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)
|
||||
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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user