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", // Mirror of the default saved view's filters, so it can be applied synchronously on load // (before the async saved-views query resolves). Kept in sync by SavedViewsWidget. defaultViewFilters: "siftlode.defaultViewFilters", page: "siftlode.page", perfMode: "siftlode.perfMode", channelFilter: "siftlode.channelFilter", channelsView: "siftlode.channelsView", settingsTab: "siftlode.settingsTab", statsTab: "siftlode.statsTab", adminUsersTab: "siftlode.adminUsersTab", navCollapsed: "siftlode.navCollapsed", filterCollapsed: "siftlode.filterCollapsed", playlist: "siftlode.playlist", plSort: "siftlode.plSort", plexLibrary: "siftlode.plexLibrary", plexShow: "siftlode.plexShow", plexSort: "siftlode.plexSort", notifHistory: "siftlode.notifications", notifSettings: "siftlode.notifSettings", onboardingDismissed: "siftlode.onboarding.dismissed", seenVersion: "siftlode.seenVersion", chatDock: (userId: number) => `siftlode.chatDock.${userId}`, } as const; // --- per-account scoping ----------------------------------------------------------------- // The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Duplicated // here (rather than imported from api.ts) so storage.ts stays dependency-free and usable by the // low-level helpers below. export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount"; export function activeAccountId(): number | null { try { const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY); if (!raw) return null; const n = Number(raw); return Number.isFinite(n) ? n : null; } catch { return null; } } /** Suffix a base localStorage key with an account id so per-account UI state (filters, selected * playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed * into one browser — a bare shared key leaks one account's state into another (and, with per-tab * accounts, two tabs would fight over it). Defaults to the current tab's active account; returns * null when no account is known yet, so callers fall back to defaults / skip persistence instead * of touching another account's data. */ export function accountKey(base: string, id: number | null | undefined = activeAccountId()): string | null { return id != null ? `${base}.${id}` : null; } /** Per-account variants of the read/write helpers: key by the current account, no-op / return the * fallback when the account isn't known (avoids reading or writing a shared/other-account key). */ export function readAccount(base: string, fallback: T): T { const k = accountKey(base); return k ? readJSON(k, fallback) : fallback; } export function readAccountMerged(base: string, defaults: T): T { const k = accountKey(base); return k ? readMerged(k, defaults) : { ...defaults }; } export function writeAccount(base: string, value: unknown): void { const k = accountKey(base); if (k) writeJSON(k, value); } export function getAccountRaw(base: string): string | null { const k = accountKey(base); return k ? localStorage.getItem(k) : null; } export function setAccountRaw(base: string, value: string): void { const k = accountKey(base); if (k) { try { localStorage.setItem(k, value); } catch { /* ignore */ } } } // --- 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]; } /** Like usePersistedState but scoped to the current account (see accountKey), so one account's * persisted tab/view position doesn't carry over to another in the same browser. These hooks run * in components that only mount once signed in, so the account is known. */ export function useAccountPersistedState( base: string, fallback = "", ): [string, (value: string) => void] { const [value, setValue] = useState(() => getAccountRaw(base) ?? fallback); const set = (next: string) => { setValue(next); setAccountRaw(base, next); }; return [value, set]; }