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:
1 parent
548343b081
commit
84ba9ffbb1
4 files changed
+333
No files matched your search
@@ -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 */
|
||||
@@ -0,0 +1 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),Line truncated
|
||||
Reference in new issue
Block a user