refactor(frontend): adopt store/storage helpers; centralize persistence
- errorDialog + hints now use createStore (drop their bespoke listener arrays). - theme/sidebarLayout/notifications/App filters/Playlists plSort use readMerged/ readJSON/writeJSON instead of inline try/JSON.parse/catch. - Stats, SettingsPanel and App's channel filter/view tabs use usePersistedState (the 3 sites that reinvented usePersistedTab inline). - Every siftlode.* key now sourced from the LS registry (no scattered literals).
This commit is contained in:
parent
634921b35a
commit
472aba6480
15 changed files with 80 additions and 147 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import i18n from "../i18n";
|
||||
import { createStore } from "./store";
|
||||
|
||||
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
|
||||
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
|
||||
|
|
@ -9,30 +10,18 @@ export interface AppError {
|
|||
message: string;
|
||||
}
|
||||
|
||||
let current: AppError | null = null;
|
||||
let listeners: Array<() => void> = [];
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
const store = createStore<AppError | null>(null);
|
||||
|
||||
export function reportError(message?: string, title?: string): void {
|
||||
current = {
|
||||
store.set({
|
||||
title: title || i18n.t("errors.title"),
|
||||
message: message || i18n.t("errors.generic"),
|
||||
};
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function dismissError(): void {
|
||||
current = null;
|
||||
emit();
|
||||
store.set(null);
|
||||
}
|
||||
|
||||
export function subscribeError(listener: () => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getError(): AppError | null {
|
||||
return current;
|
||||
}
|
||||
export const subscribeError = store.subscribe;
|
||||
export const getError = store.get;
|
||||
|
|
|
|||
|
|
@ -1,36 +1,27 @@
|
|||
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
||||
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
||||
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||
const KEY = "siftlode.hints";
|
||||
|
||||
let enabled = load();
|
||||
let listeners: Array<() => void> = [];
|
||||
import { createStore } from "./store";
|
||||
import { LS } from "./storage";
|
||||
|
||||
function load(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(KEY) !== "0"; // default ON
|
||||
return localStorage.getItem(LS.hints) !== "0"; // default ON
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function hintsEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
const store = createStore<boolean>(load());
|
||||
|
||||
export const hintsEnabled = store.get;
|
||||
export const subscribeHints = store.subscribe;
|
||||
|
||||
export function setHintsEnabled(v: boolean): void {
|
||||
enabled = v;
|
||||
store.set(v);
|
||||
try {
|
||||
localStorage.setItem(KEY, v ? "1" : "0");
|
||||
localStorage.setItem(LS.hints, v ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeHints(listener: () => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// callbacks are live-only and dropped on reload.
|
||||
import { LS, readJSON, readMerged, writeJSON } from "./storage";
|
||||
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
|
||||
|
|
@ -71,8 +72,8 @@ export interface NotifyInput {
|
|||
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
||||
}
|
||||
|
||||
const HISTORY_KEY = "siftlode.notifications";
|
||||
const SETTINGS_KEY = "siftlode.notifSettings";
|
||||
const HISTORY_KEY = LS.notifHistory;
|
||||
const SETTINGS_KEY = LS.notifSettings;
|
||||
const MAX_HISTORY = 100;
|
||||
const ERROR_TTL = 15000;
|
||||
|
||||
|
|
@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
|||
let config: NotifSettings = loadSettings();
|
||||
|
||||
function loadSettings(): NotifSettings {
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
export function getNotifSettings(): NotifSettings {
|
||||
|
|
@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings {
|
|||
|
||||
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||
config = { ...config, ...p };
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeJSON(SETTINGS_KEY, config);
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
|
@ -137,7 +130,7 @@ let cachedUnread = 0;
|
|||
|
||||
function load(): Notification[] {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
||||
const raw = readJSON<unknown>(HISTORY_KEY, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
// Restored entries are history only. Mark them dismissed so they don't
|
||||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||
|
|
@ -154,7 +147,7 @@ function persist() {
|
|||
// Transient status notices are live-session only — never write them to history,
|
||||
// so a reload can't leave one stranded with no producer left to clear it.
|
||||
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
||||
writeJSON(HISTORY_KEY, slim);
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@
|
|||
// suppresses the auto-popup on future logins (they
|
||||
// can still reopen it from Settings → Account).
|
||||
|
||||
export const ONBOARD_ACTIVE = "siftlode.onboarding.active";
|
||||
export const ONBOARD_DISMISSED = "siftlode.onboarding.dismissed";
|
||||
import { LS } from "./storage";
|
||||
|
||||
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
|
||||
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||
|
||||
export function shouldAutoOpenOnboarding(me: {
|
||||
can_read: boolean;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
||||
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||
import { LS, readJSON, writeJSON } from "./storage";
|
||||
|
||||
export type WidgetId = "date" | "language" | "topic" | "tags";
|
||||
|
||||
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
|
||||
|
|
@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = {
|
|||
hidden: {},
|
||||
};
|
||||
|
||||
const KEY = "siftlode.sidebarLayout";
|
||||
|
||||
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
||||
// (e.g. a widget added in a later version) so nothing silently disappears.
|
||||
export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||
|
|
@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
|
|||
}
|
||||
|
||||
export function loadLayout(): SidebarLayout {
|
||||
try {
|
||||
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
|
||||
} catch {
|
||||
return DEFAULT_LAYOUT;
|
||||
}
|
||||
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
|
||||
}
|
||||
|
||||
export function saveLayoutLocal(l: SidebarLayout): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(l));
|
||||
writeJSON(LS.sidebarLayout, l);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { LS, readMerged, writeJSON } from "./storage";
|
||||
|
||||
export type Mode = "dark" | "light";
|
||||
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
||||
|
||||
|
|
@ -27,16 +29,10 @@ export function applyTheme(t: ThemePrefs): void {
|
|||
el.style.setProperty("--font-scale", String(t.fontScale));
|
||||
}
|
||||
|
||||
const KEY = "siftlode.theme";
|
||||
|
||||
export function loadLocalTheme(): ThemePrefs {
|
||||
try {
|
||||
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
return readMerged(LS.theme, DEFAULT_THEME);
|
||||
}
|
||||
|
||||
export function saveLocalTheme(t: ThemePrefs): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(t));
|
||||
writeJSON(LS.theme, t);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
|
||||
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
|
||||
import { LS } from "./storage";
|
||||
|
||||
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
||||
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
||||
|
||||
// Key for the "last version the user has seen release notes for" (drives the banner).
|
||||
export const SEEN_VERSION_KEY = "siftlode.seenVersion";
|
||||
export const SEEN_VERSION_KEY = LS.seenVersion;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue