feat(frontend): add createStore factory + central localStorage registry
- lib/store.ts: createStore<T> — one observable-value primitive (get/set/subscribe/use) replacing the hand-rolled listeners/subscribe/emit triad several modules each had. - lib/storage.ts: LS key registry (every siftlode.* key in one place), readMerged/ readJSON/writeJSON (the try/JSON.parse/merge/catch done once), and usePersistedState (the reactive persisted-string hook, generalizing usePersistedTab).
This commit is contained in:
parent
239a298992
commit
634921b35a
2 changed files with 113 additions and 0 deletions
80
frontend/src/lib/storage.ts
Normal file
80
frontend/src/lib/storage.ts
Normal file
|
|
@ -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<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];
|
||||||
|
}
|
||||||
33
frontend/src/lib/store.ts
Normal file
33
frontend/src/lib/store.ts
Normal file
|
|
@ -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<T> {
|
||||||
|
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<T>(initial: T): Store<T> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue