feat(phase3): PWA, auto-discovery, bookmarklet, multi-tab sync

- PWA: manifest, service worker (cache-first static, network-first API),
  offline page, install prompt banner
- Auto-discovery: Docker socket + Traefik API scanning, approval UI
- Quick-add bookmarklet: popup-based add page, favicon auto-detect
- Multi-tab sync: BroadcastChannel for theme + data changes
- i18n translations for all new strings (EN/RU)
This commit is contained in:
2026-03-25 00:59:19 +03:00
parent c6a7de895d
commit dd6958b4d6
28 changed files with 1712 additions and 266 deletions
+38
View File
@@ -1,3 +1,5 @@
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';
@@ -36,6 +38,7 @@ class ThemeStore {
backgroundType = $state<BackgroundType>('mesh');
#systemPreference: 'dark' | 'light' = 'dark';
#suppressBroadcast = false;
resolvedMode = $derived<'dark' | 'light'>(
this.mode === 'system' ? this.#systemPreference : this.mode
@@ -98,6 +101,20 @@ class ThemeStore {
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
};
if (typeof window === 'undefined') return;
if (this.#suppressBroadcast) return;
broadcastThemeChange(snapshot);
});
}
cycleMode() {
@@ -119,6 +136,27 @@ class ThemeStore {
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;
}) {
this.#suppressBroadcast = true;
this.mode = values.mode;
this.primaryHue = values.primaryHue;
this.primarySaturation = values.primarySaturation;
this.backgroundType = values.backgroundType;
// Re-enable on next microtask so the effect reads suppressBroadcast=true
queueMicrotask(() => {
this.#suppressBroadcast = false;
});
}
/**
* Apply non-null server-stored user preferences over localStorage defaults.
* Call from +layout.svelte when user data is available.