feat: add web frontend directory structure
- Create web/src/ for TypeScript source files - Create web/static/ for compiled assets and runtime files - Move input.css and style.css to web/static/ - Add toast.ts - Functional toast notification system - Add theme.ts - Functional theme management system - All code uses functional programming (no classes, no OOP) - TypeScript provides full type safety Separates frontend code from backend for better organization.
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
// Theme management functionality
|
||||||
|
|
||||||
|
type ThemeType =
|
||||||
|
| 'tokyo-night'
|
||||||
|
| 'dracula'
|
||||||
|
| 'nord'
|
||||||
|
| 'solarized-dark'
|
||||||
|
| 'monokai'
|
||||||
|
| 'one-dark-pro'
|
||||||
|
| 'material-dark'
|
||||||
|
| 'catppuccin-mocha'
|
||||||
|
| 'catppuccin-macchiato'
|
||||||
|
| 'catppuccin-frappe'
|
||||||
|
| 'catppuccin-latte';
|
||||||
|
|
||||||
|
const DEFAULT_THEME: ThemeType = 'tokyo-night';
|
||||||
|
const THEME_STORAGE_KEY = 'theme';
|
||||||
|
const TOKEN_STORAGE_KEY = 'token';
|
||||||
|
|
||||||
|
// Apply theme to document body
|
||||||
|
const applyTheme = (theme: ThemeType): void => {
|
||||||
|
document.body.className = `theme-${theme}`;
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load theme from localStorage or use default
|
||||||
|
const loadTheme = (): void => {
|
||||||
|
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
|
||||||
|
const theme = storedTheme || DEFAULT_THEME;
|
||||||
|
applyTheme(theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle theme change from user selection
|
||||||
|
const changeTheme = async (): Promise<void> => {
|
||||||
|
const themeSelect = document.getElementById('theme-select') as HTMLSelectElement;
|
||||||
|
if (!themeSelect) return;
|
||||||
|
|
||||||
|
const theme = themeSelect.value as ThemeType;
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
// Save to server if logged in
|
||||||
|
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/theme', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ theme })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('Theme save failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Theme save failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load user's theme from server if logged in
|
||||||
|
const loadUserTheme = async (): Promise<void> => {
|
||||||
|
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/profile', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.theme) {
|
||||||
|
applyTheme(data.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail - user will get default theme
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize theme system
|
||||||
|
const initializeTheme = (): void => {
|
||||||
|
loadTheme();
|
||||||
|
loadUserTheme();
|
||||||
|
|
||||||
|
// Set theme select value to current theme
|
||||||
|
const themeSelect = document.getElementById('theme-select') as HTMLSelectElement;
|
||||||
|
if (themeSelect) {
|
||||||
|
const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
|
||||||
|
themeSelect.value = currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup smooth scroll for anchor links
|
||||||
|
setupSmoothScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup smooth scrolling for anchor links
|
||||||
|
const setupSmoothScroll = (): void => {
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const href = anchor.getAttribute('href');
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
const target = document.querySelector(href);
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-initialize when DOM is ready
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeTheme);
|
||||||
|
} else {
|
||||||
|
initializeTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make changeTheme available globally for HTML onchange attribute
|
||||||
|
(window as any).changeTheme = changeTheme;
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// 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 beforeSwap event
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', (evt: Event) => {
|
||||||
|
const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>;
|
||||||
|
if (customEvent.detail.xhr && customEvent.detail.xhr.status >= 400) {
|
||||||
|
const xhr = customEvent.detail.xhr;
|
||||||
|
const errorMessage = parseXHRError(xhr);
|
||||||
|
showToast(errorMessage, 'error');
|
||||||
|
evt.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for HTMX responseError event
|
||||||
|
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
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
showToast: {
|
||||||
|
error: (message: string, duration?: number) => void;
|
||||||
|
success: (message: string, duration?: number) => void;
|
||||||
|
info: (message: string, duration?: number) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.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)
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Custom theme variables are handled in CSS custom properties in templates */
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user