/** * Theme Store for Dockhand * * Manages theme and font preferences with: * - Immediate application (no page reload) * - localStorage sync for flash-free loading * - Database persistence via API */ import { writable, get } from 'svelte/store'; import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes'; export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge'; export interface ThemePreferences { lightTheme: string; darkTheme: string; font: string; fontSize: FontSize; gridFontSize: FontSize; terminalFont: string; } const STORAGE_KEY = 'dockhand-theme'; const defaultPrefs: ThemePreferences = { lightTheme: 'default', darkTheme: 'default', font: 'system', fontSize: 'normal', gridFontSize: 'normal', terminalFont: 'system-mono' }; // Font size scale mapping const fontSizeScales: Record = { xsmall: 0.75, small: 0.875, normal: 1.0, medium: 1.0625, large: 1.125, xlarge: 1.25 }; // Grid font size scale - independent scaling for data grids const gridFontSizeScales: Record = { xsmall: 0.7, small: 0.85, normal: 1.0, medium: 1.15, large: 1.35, xlarge: 1.7 }; // Load initial state from localStorage (for flash-free loading) function loadFromStorage(): ThemePreferences { if (typeof window === 'undefined') return defaultPrefs; try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { return { ...defaultPrefs, ...JSON.parse(stored) }; } } catch { // Ignore parse errors } return defaultPrefs; } // Create the store function createThemeStore() { const initialPrefs = loadFromStorage(); const { subscribe, set, update } = writable(initialPrefs); // Apply theme immediately on store creation (for flash-free loading) if (typeof document !== 'undefined') { applyTheme(initialPrefs); } return { subscribe, // Initialize from API (called on mount) async init(userId?: number) { try { const url = userId ? `/api/profile/preferences` : `/api/settings/general`; const res = await fetch(url); if (res.ok) { const data = await res.json(); const prefs: ThemePreferences = { lightTheme: data.lightTheme || data.theme_light || 'default', darkTheme: data.darkTheme || data.theme_dark || 'default', font: data.font || data.theme_font || 'system', fontSize: data.fontSize || data.font_size || 'normal', gridFontSize: data.gridFontSize || data.grid_font_size || 'normal', terminalFont: data.terminalFont || data.terminal_font || 'system-mono' }; set(prefs); saveToStorage(prefs); applyTheme(prefs); } } catch { // Use localStorage fallback const prefs = loadFromStorage(); applyTheme(prefs); } }, // Update a preference and apply immediately async setPreference( key: K, value: ThemePreferences[K], userId?: number ) { update((prefs) => { const newPrefs = { ...prefs, [key]: value }; saveToStorage(newPrefs); applyTheme(newPrefs); return newPrefs; }); // Save to database (async, non-blocking) try { const url = userId ? `/api/profile/preferences` : `/api/settings/general`; await fetch(url, { method: userId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [key]: value }) }); } catch { // Silently fail - localStorage has the value } }, // Get current preferences get(): ThemePreferences { return get({ subscribe }); } }; } // Save to localStorage function saveToStorage(prefs: ThemePreferences) { if (typeof window === 'undefined') return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch { // Ignore storage errors } } // Apply theme to document export function applyTheme(prefs: ThemePreferences) { if (typeof document === 'undefined') return; const root = document.documentElement; const isDark = root.classList.contains('dark'); // Remove all theme classes root.classList.forEach((cls) => { if (cls.startsWith('theme-light-') || cls.startsWith('theme-dark-')) { root.classList.remove(cls); } }); // Apply the appropriate theme class if (isDark && prefs.darkTheme !== 'default') { root.classList.add(`theme-dark-${prefs.darkTheme}`); } else if (!isDark && prefs.lightTheme !== 'default') { root.classList.add(`theme-light-${prefs.lightTheme}`); } // Apply font applyFont(prefs.font); // Apply font size applyFontSize(prefs.fontSize); // Apply grid font size applyGridFontSize(prefs.gridFontSize); // Apply terminal font applyTerminalFont(prefs.terminalFont); } // Apply font to document function applyFont(fontId: string) { if (typeof document === 'undefined') return; const fontMeta = getFont(fontId); if (!fontMeta) return; // Load Google Font if needed if (fontMeta.googleFont) { loadGoogleFont(fontMeta); } // Set CSS variable document.documentElement.style.setProperty('--font-sans', fontMeta.family); } // Apply font size to document function applyFontSize(fontSize: FontSize) { if (typeof document === 'undefined') return; const scale = fontSizeScales[fontSize] || 1.0; document.documentElement.style.setProperty('--font-size-scale', scale.toString()); } // Apply grid font size to document function applyGridFontSize(gridFontSize: FontSize) { if (typeof document === 'undefined') return; const gridScale = gridFontSizeScales[gridFontSize] || 1.0; document.documentElement.style.setProperty('--grid-font-size-scale', gridScale.toString()); } // Apply terminal font to document function applyTerminalFont(fontId: string) { if (typeof document === 'undefined') return; const fontMeta = getMonospaceFont(fontId); if (!fontMeta) return; // Load Google Font if needed if (fontMeta.googleFont) { loadGoogleFont(fontMeta); } // Set CSS variable document.documentElement.style.setProperty('--font-mono', fontMeta.family); } // Load Google Font dynamically function loadGoogleFont(font: FontMeta) { if (!font.googleFont) return; const linkId = `google-font-${font.id}`; if (document.getElementById(linkId)) return; // Already loaded const link = document.createElement('link'); link.id = linkId; link.rel = 'stylesheet'; link.href = `https://fonts.googleapis.com/css2?family=${font.googleFont}&display=swap`; document.head.appendChild(link); } // Re-apply theme when dark mode toggles export function onDarkModeChange() { const prefs = themeStore.get(); applyTheme(prefs); } export const themeStore = createThemeStore();