1c0a7cb850
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
206 lines
5.9 KiB
TypeScript
206 lines
5.9 KiB
TypeScript
import { broadcastThemeChange } from '$lib/utils/broadcastSync.js';
|
|
|
|
const THEME_STORAGE_KEY = 'wal-theme-mode';
|
|
const PRIMARY_HUE_KEY = 'wal-primary-hue';
|
|
const PRIMARY_SAT_KEY = 'wal-primary-sat';
|
|
const BG_TYPE_KEY = 'wal-bg-type';
|
|
const CARD_STYLE_KEY = 'wal-card-style';
|
|
|
|
export type ThemeMode = 'dark' | 'light' | 'system';
|
|
export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none' | 'wallpaper';
|
|
export type CardStyle = 'solid' | 'glass' | 'outline';
|
|
|
|
function getStoredValue<T>(key: string, fallback: T): T {
|
|
if (typeof window === 'undefined') return fallback;
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored === null) return fallback;
|
|
return stored as unknown as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function getStoredNumber(key: string, fallback: number): number {
|
|
if (typeof window === 'undefined') return fallback;
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored === null) return fallback;
|
|
const parsed = Number(stored);
|
|
return Number.isNaN(parsed) ? fallback : parsed;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
class ThemeStore {
|
|
mode = $state<ThemeMode>('system');
|
|
primaryHue = $state(220);
|
|
primarySaturation = $state(70);
|
|
backgroundType = $state<BackgroundType>('mesh');
|
|
cardStyle = $state<CardStyle>('solid');
|
|
|
|
#systemPreference: 'dark' | 'light' = 'dark';
|
|
#suppressBroadcast = false;
|
|
|
|
resolvedMode = $derived<'dark' | 'light'>(
|
|
this.mode === 'system' ? this.#systemPreference : this.mode
|
|
);
|
|
|
|
isDark = $derived(this.resolvedMode === 'dark');
|
|
|
|
constructor() {
|
|
if (typeof window !== 'undefined') {
|
|
this.mode = getStoredValue<ThemeMode>(THEME_STORAGE_KEY, 'system');
|
|
this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220);
|
|
this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70);
|
|
this.backgroundType = getStoredValue<BackgroundType>(BG_TYPE_KEY, 'mesh');
|
|
this.cardStyle = getStoredValue<CardStyle>(CARD_STYLE_KEY, 'solid');
|
|
|
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
|
this.#systemPreference = mql.matches ? 'dark' : 'light';
|
|
mql.addEventListener('change', (e) => {
|
|
this.#systemPreference = e.matches ? 'dark' : 'light';
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Must be called from within a component to set up persistence and DOM effects */
|
|
initEffects() {
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(THEME_STORAGE_KEY, this.mode);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(PRIMARY_HUE_KEY, String(this.primaryHue));
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(PRIMARY_SAT_KEY, String(this.primarySaturation));
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(BG_TYPE_KEY, this.backgroundType);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(CARD_STYLE_KEY, this.cardStyle);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof document === 'undefined') return;
|
|
const html = document.documentElement;
|
|
if (this.resolvedMode === 'dark') {
|
|
html.classList.add('dark');
|
|
html.classList.remove('light');
|
|
} else {
|
|
html.classList.remove('dark');
|
|
html.classList.add('light');
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof document === 'undefined') return;
|
|
const html = document.documentElement;
|
|
html.style.setProperty('--primary-h', String(this.primaryHue));
|
|
html.style.setProperty('--primary-s', `${this.primarySaturation}%`);
|
|
});
|
|
|
|
// Broadcast theme changes to other tabs
|
|
$effect(() => {
|
|
// Read all reactive values to track them
|
|
const snapshot = {
|
|
mode: this.mode,
|
|
primaryHue: this.primaryHue,
|
|
primarySaturation: this.primarySaturation,
|
|
backgroundType: this.backgroundType,
|
|
cardStyle: this.cardStyle
|
|
};
|
|
if (typeof window === 'undefined') return;
|
|
if (this.#suppressBroadcast) return;
|
|
broadcastThemeChange(snapshot);
|
|
});
|
|
}
|
|
|
|
cycleMode() {
|
|
const modes: ThemeMode[] = ['light', 'dark', 'system'];
|
|
const idx = modes.indexOf(this.mode);
|
|
this.mode = modes[(idx + 1) % modes.length];
|
|
}
|
|
|
|
setMode(mode: ThemeMode) {
|
|
this.mode = mode;
|
|
}
|
|
|
|
setBackground(bg: BackgroundType) {
|
|
this.backgroundType = bg;
|
|
}
|
|
|
|
setCardStyle(style: CardStyle) {
|
|
this.cardStyle = style;
|
|
}
|
|
|
|
setPrimaryColor(hue: number, saturation: number) {
|
|
this.primaryHue = Math.max(0, Math.min(360, hue));
|
|
this.primarySaturation = Math.max(0, Math.min(100, saturation));
|
|
}
|
|
|
|
/**
|
|
* Apply theme values received from another tab via BroadcastChannel.
|
|
* Suppresses re-broadcasting to avoid echo loops.
|
|
*/
|
|
applyFromBroadcast(values: {
|
|
mode: ThemeMode;
|
|
primaryHue: number;
|
|
primarySaturation: number;
|
|
backgroundType: BackgroundType;
|
|
cardStyle?: CardStyle;
|
|
}) {
|
|
this.#suppressBroadcast = true;
|
|
this.mode = values.mode;
|
|
this.primaryHue = values.primaryHue;
|
|
this.primarySaturation = values.primarySaturation;
|
|
this.backgroundType = values.backgroundType;
|
|
if (values.cardStyle) this.cardStyle = values.cardStyle;
|
|
// Use setTimeout to ensure all Svelte 5 effects have fired before re-enabling broadcast
|
|
setTimeout(() => {
|
|
this.#suppressBroadcast = false;
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Apply non-null server-stored user preferences over localStorage defaults.
|
|
* Call from +layout.svelte when user data is available.
|
|
*/
|
|
loadFromServer(prefs: {
|
|
themeMode?: string | null;
|
|
primaryHue?: number | null;
|
|
primarySaturation?: number | null;
|
|
backgroundType?: string | null;
|
|
cardStyle?: string | null;
|
|
}) {
|
|
if (prefs.themeMode != null) {
|
|
this.mode = prefs.themeMode as ThemeMode;
|
|
}
|
|
if (prefs.primaryHue != null) {
|
|
this.primaryHue = prefs.primaryHue;
|
|
}
|
|
if (prefs.primarySaturation != null) {
|
|
this.primarySaturation = prefs.primarySaturation;
|
|
}
|
|
if (prefs.backgroundType != null) {
|
|
this.backgroundType = prefs.backgroundType as BackgroundType;
|
|
}
|
|
if (prefs.cardStyle != null) {
|
|
this.cardStyle = prefs.cardStyle as CardStyle;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const theme = new ThemeStore();
|