// Reader preferences. Stored server-side per user, with a localStorage // shadow so anonymous browsers still get a consistent experience across // page loads and so logged-in users don't flash defaults while the // server response is in flight. // // Mutated client-side only — same pattern as session.svelte.ts. SSR // always sees the post-construct defaults; `init()` is called from the // root layout's onMount and reads localStorage / pulls the server row. import { getPreferences, updatePreferences, type Preferences, type ReaderMode, type ReaderPageGap } from './api/preferences'; const MODE_KEY = 'mangalord-reader-mode'; const GAP_KEY = 'mangalord-reader-gap'; const MODES: ReaderMode[] = ['single', 'continuous']; const GAPS: ReaderPageGap[] = ['none', 'small', 'medium', 'large']; function readStoredMode(): ReaderMode { if (typeof localStorage === 'undefined') return 'single'; const v = localStorage.getItem(MODE_KEY); return (MODES as string[]).includes(v ?? '') ? (v as ReaderMode) : 'single'; } function readStoredGap(): ReaderPageGap { if (typeof localStorage === 'undefined') return 'none'; const v = localStorage.getItem(GAP_KEY); return (GAPS as string[]).includes(v ?? '') ? (v as ReaderPageGap) : 'none'; } class PreferencesStore { readerMode: ReaderMode = $state('single'); readerPageGap: ReaderPageGap = $state('none'); loaded = $state(false); // Bumped before each server fetch so a slow response that resolves // after a newer one can't clobber the state. private seq = 0; /** * One-shot init: hydrate from localStorage (instant), then ask the * server (slow but authoritative). Safe to call multiple times — the * seq guard makes overlapping calls converge on the latest result. */ async init(): Promise { this.readerMode = readStoredMode(); this.readerPageGap = readStoredGap(); await this.refresh(); } async refresh(): Promise { if (typeof window === 'undefined') return; const seq = ++this.seq; try { const server = await getPreferences(); if (seq !== this.seq) return; if (server) this.apply(server); } finally { if (seq === this.seq) this.loaded = true; } } /// Reset to defaults and clear the localStorage shadow. Called on /// logout so user A's settings don't follow user B (or an anonymous /// browser) on a shared device. clearForLogout(): void { this.seq++; this.readerMode = 'single'; this.readerPageGap = 'none'; if (typeof localStorage !== 'undefined') { localStorage.removeItem(MODE_KEY); localStorage.removeItem(GAP_KEY); } } setMode(mode: ReaderMode): void { const prev = this.readerMode; this.readerMode = mode; if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, mode); this.pushToServer({ reader_mode: mode }, () => { this.readerMode = prev; if (typeof localStorage !== 'undefined') localStorage.setItem(MODE_KEY, prev); }); } setGap(gap: ReaderPageGap): void { const prev = this.readerPageGap; this.readerPageGap = gap; if (typeof localStorage !== 'undefined') localStorage.setItem(GAP_KEY, gap); this.pushToServer({ reader_page_gap: gap }, () => { this.readerPageGap = prev; if (typeof localStorage !== 'undefined') localStorage.setItem(GAP_KEY, prev); }); } private apply(p: Preferences): void { // Validate against the allowlist so a future server-side value // we don't yet understand can't poison the UI (e.g. an unknown // gap would render `style:gap="undefinedpx"` and break layout). const mode = (MODES as string[]).includes(p.reader_mode) ? (p.reader_mode as ReaderMode) : this.readerMode; const gap = (GAPS as string[]).includes(p.reader_page_gap) ? (p.reader_page_gap as ReaderPageGap) : this.readerPageGap; this.readerMode = mode; this.readerPageGap = gap; if (typeof localStorage !== 'undefined') { localStorage.setItem(MODE_KEY, mode); localStorage.setItem(GAP_KEY, gap); } } private pushToServer( patch: Partial>, revert: () => void ): void { // Optimistic local update already happened. 401 (anonymous user) // is the expected path for guests — the localStorage write keeps // their choice. Any other failure (network, 5xx) means the // server doesn't agree with our local state, so roll the // optimistic change back. updatePreferences(patch).catch((e) => { const status = (e as { status?: number })?.status; if (status === 401) return; console.error('updatePreferences failed', e); revert(); }); } } export const preferences = new PreferencesStore();