diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..80648d4 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,80 @@ +import { useState } from "react"; + +// Central registry of every localStorage key the app uses. One place to see them all, rename +// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across +// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`. +export const LS = { + theme: "siftlode.theme", + sidebarLayout: "siftlode.sidebarLayout", + hints: "siftlode.hints", + lang: "siftlode.lang", + filters: "siftlode.filters", + page: "siftlode.page", + perfMode: "siftlode.perfMode", + channelFilter: "siftlode.channelFilter", + channelsView: "siftlode.channelsView", + settingsTab: "siftlode.settingsTab", + statsTab: "siftlode.statsTab", + adminUsersTab: "siftlode.adminUsersTab", + navCollapsed: "siftlode.navCollapsed", + playlist: "siftlode.playlist", + plSort: "siftlode.plSort", + notifHistory: "siftlode.notifications", + notifSettings: "siftlode.notifSettings", + onboardingDismissed: "siftlode.onboarding.dismissed", + seenVersion: "siftlode.seenVersion", + chatDock: (userId: number) => `siftlode.chatDock.${userId}`, +} as const; + +// --- typed read/write helpers (do the JSON + try/catch once) ---------------------------- + +/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated), + * falling back to `defaults` on missing or corrupt data. */ +export function readMerged(key: string, defaults: T): T { + try { + return { ...defaults, ...JSON.parse(localStorage.getItem(key) || "{}") }; + } catch { + return { ...defaults }; + } +} + +/** Read any JSON value (arrays/scalars), falling back on missing or corrupt data. */ +export function readJSON(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw == null ? fallback : (JSON.parse(raw) as T); + } catch { + return fallback; + } +} + +/** Persist a value as JSON, swallowing quota/serialization errors. */ +export function writeJSON(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* ignore quota / serialization errors */ + } +} + +// --- reactive persisted string state ---------------------------------------------------- + +/** A `useState` whose string value is mirrored to localStorage, so it survives a reload (F5). + * Validation is deferred to the caller (clamp at render) so it can be used before an + * async-loaded option list is known, keeping hook order stable. Generalizes the per-component + * "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter…). */ +export function usePersistedState( + key: string, + fallback = "", +): [string, (value: string) => void] { + const [value, setValue] = useState(() => localStorage.getItem(key) ?? fallback); + const set = (next: string) => { + setValue(next); + try { + localStorage.setItem(key, next); + } catch { + /* ignore */ + } + }; + return [value, set]; +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..8f68e9a --- /dev/null +++ b/frontend/src/lib/store.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from "react"; + +// A minimal observable value shared across React components AND non-React code (the api +// layer, websocket handlers, etc.). Replaces the hand-rolled `listeners`/`subscribe`/`emit` +// triad that several modules each re-implemented slightly differently. +export interface Store { + get(): T; + set(next: T | ((prev: T) => T)): void; + subscribe(listener: () => void): () => void; + /** Subscribe from a component; re-renders on every change. */ + use(): T; +} + +export function createStore(initial: T): Store { + let value = initial; + const listeners = new Set<() => void>(); + const get = () => value; + const subscribe = (l: () => void) => { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }; + return { + get, + set(next) { + value = typeof next === "function" ? (next as (p: T) => T)(value) : next; + listeners.forEach((l) => l()); + }, + subscribe, + use: () => useSyncExternalStore(subscribe, get, get), + }; +}