mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 13:22:54 +00:00
Initial commit
This commit is contained in:
260
lib/stores/theme.ts
Normal file
260
lib/stores/theme.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user