2026-06-26 03:30:19 +02:00
|
|
|
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",
|
2026-07-01 03:17:36 +02:00
|
|
|
// 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",
|
2026-06-26 03:30:19 +02:00
|
|
|
page: "siftlode.page",
|
|
|
|
|
perfMode: "siftlode.perfMode",
|
|
|
|
|
channelFilter: "siftlode.channelFilter",
|
|
|
|
|
channelsView: "siftlode.channelsView",
|
|
|
|
|
settingsTab: "siftlode.settingsTab",
|
|
|
|
|
statsTab: "siftlode.statsTab",
|
|
|
|
|
adminUsersTab: "siftlode.adminUsersTab",
|
|
|
|
|
navCollapsed: "siftlode.navCollapsed",
|
2026-07-01 23:08:25 +02:00
|
|
|
filterCollapsed: "siftlode.filterCollapsed",
|
2026-06-26 03:30:19 +02:00
|
|
|
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) ----------------------------
|
|
|
|
|
|
2026-07-02 00:37:23 +02:00
|
|
|
/** Suffix a base localStorage key with an account id. Per-account UI state (the feed filters and
|
|
|
|
|
* the default-view mirror) must NOT be shared across the accounts signed into one browser — a
|
|
|
|
|
* bare shared key leaks one account's view into another (and, with per-tab accounts, two tabs
|
|
|
|
|
* would fight over it). Returns null when the account isn't 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): string | null {
|
|
|
|
|
return id != null ? `${base}.${id}` : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 03:30:19 +02:00
|
|
|
/** 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<T extends object>(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<T>(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<string>(() => localStorage.getItem(key) ?? fallback);
|
|
|
|
|
const set = (next: string) => {
|
|
|
|
|
setValue(next);
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(key, next);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
return [value, set];
|
|
|
|
|
}
|