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:
2026-01-29 14:08:44 -05:00
parent 548343b081
commit 84ba9ffbb1
4 changed files with 333 additions and 0 deletions
+129
View File
@@ -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;
+198
View File
@@ -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 {};
+5
View File
@@ -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