Files
web-app-launcher/src/lib/stores/theme.svelte.ts
T
alexei.dolgolyov 1c0a7cb850 feat: Phases 4-7 — Full Feature Expansion (26 features)
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.
2026-03-25 14:18:10 +03:00

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();