Files
dockhand/lib/stores/theme.ts
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

261 lines
6.5 KiB
TypeScript

/**
* 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<FontSize, number> = {
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<FontSize, number> = {
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<ThemePreferences>(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<K extends keyof ThemePreferences>(
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();