From 472aba648001eccaf48650433bc7ed5a1a69d11f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 26 Jun 2026 03:30:19 +0200 Subject: [PATCH] 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). --- frontend/src/App.tsx | 45 ++++++++--------------- frontend/src/components/AdminUsers.tsx | 3 +- frontend/src/components/NavSidebar.tsx | 5 ++- frontend/src/components/Playlists.tsx | 17 +++------ frontend/src/components/SettingsPanel.tsx | 19 +++------- frontend/src/components/Stats.tsx | 13 ++----- frontend/src/components/Tabs.tsx | 15 ++------ frontend/src/i18n/index.ts | 3 +- frontend/src/lib/errorDialog.ts | 25 ++++--------- frontend/src/lib/hints.ts | 27 +++++--------- frontend/src/lib/notifications.ts | 21 ++++------- frontend/src/lib/onboarding.ts | 6 ++- frontend/src/lib/sidebarLayout.ts | 12 ++---- frontend/src/lib/theme.ts | 12 ++---- frontend/src/lib/version.ts | 4 +- 15 files changed, 80 insertions(+), 147 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2176d6a..3e3ee64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { } from "./lib/sidebarLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; +import { LS, readMerged, usePersistedState } from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Welcome from "./components/Welcome"; @@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = { show: "unwatched", }; -const FILTERS_KEY = "siftlode.filters"; -const PAGE_KEY = "siftlode.page"; -const PERF_KEY = "siftlode.perfMode"; +const FILTERS_KEY = LS.filters; +const PAGE_KEY = LS.page; +const PERF_KEY = LS.perfMode; // The preferences edited on the Settings page. They apply locally for instant preview but // persist to the server only on an explicit Save — so App holds both the live "draft" (the @@ -84,11 +85,7 @@ function loadInitialPage(): Page { } function loadStoredFilters(): FeedFilters { - try { - return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") }; - } catch { - return DEFAULT_FILTERS; - } + return readMerged(FILTERS_KEY, DEFAULT_FILTERS); } // URL wins over localStorage so a pasted link reproduces the exact view. @@ -126,18 +123,13 @@ export default function App() { const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); const [wizardOpen, setWizardOpen] = useState(false); - const CHANNEL_FILTER_KEY = "siftlode.channelFilter"; - const [channelFilter, setChannelFilterState] = useState(() => { - const v = localStorage.getItem(CHANNEL_FILTER_KEY); - return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all" - ? v - : "all"; - }); - // Persist the channel status chip so a reload (F5) keeps it. - const setChannelFilter = (f: ChannelStatusFilter) => { - setChannelFilterState(f); - localStorage.setItem(CHANNEL_FILTER_KEY, f); - }; + // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. + const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); + const channelFilter: ChannelStatusFilter = ( + ["needs_full", "fully_synced", "hidden", "all"] as const + ).includes(channelFilterRaw as ChannelStatusFilter) + ? (channelFilterRaw as ChannelStatusFilter) + : "all"; const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -145,14 +137,9 @@ export default function App() { // persisted so navigation intents that target the subscriptions table — the header's // "without full history" link, focus-channel — can force it back to "subscribed" instead // of dumping the user on whatever tab they last left open. - const CHANNELS_VIEW_KEY = "siftlode.channelsView"; - const [channelsView, setChannelsViewState] = useState(() => - localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed" - ); - const setChannelsView = (v: ChannelsView) => { - setChannelsViewState(v); - localStorage.setItem(CHANNELS_VIEW_KEY, v); - }; + const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed"); + const channelsView: ChannelsView = + channelsViewRaw === "discovery" ? "discovery" : "subscribed"; // Bumped to tell the channel manager to drop a stale column filter when we send the user // there to see a specific set (the header's "without full history" link). const [channelsFilterReset, setChannelsFilterReset] = useState(0); @@ -318,7 +305,7 @@ export default function App() { if (result === "ok") { notify({ level: "success", message: t("settings.account.googleLink.linked") }); void meQuery.refetch(); - localStorage.setItem("siftlode.settingsTab", "account"); + localStorage.setItem(LS.settingsTab, "account"); setPage("settings"); } else { const key = result === "conflict" || result === "mismatch" ? result : "error"; diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index e90b8e5..078ae9c 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; +import { LS } from "../lib/storage"; import Tooltip from "./Tooltip"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; @@ -14,7 +15,7 @@ import Tabs, { usePersistedTab } from "./Tabs"; // The persisted-tab storage key + the access-requests tab id, exported so a notification's // "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key // on mount, and this page mounts fresh on navigation). -export const ADMIN_USERS_TAB_KEY = "siftlode.adminUsersTab"; +export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 1822a3e..cd23aff 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -23,6 +23,7 @@ import { import { api, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; +import { LS } from "../lib/storage"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; @@ -48,7 +49,7 @@ export default function NavSidebar({ }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( - () => localStorage.getItem("siftlode.navCollapsed") === "1" + () => localStorage.getItem(LS.navCollapsed) === "1" ); const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); @@ -90,7 +91,7 @@ export default function NavSidebar({ function toggleCollapsed() { setCollapsed((c) => { const next = !c; - localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0"); + localStorage.setItem(LS.navCollapsed, next ? "1" : "0"); return next; }); } diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 8c49f64..dbdba96 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -37,6 +37,7 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; +import { LS, readMerged, writeJSON } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; import PlayerModal from "./PlayerModal"; import UndoToolbar from "./UndoToolbar"; @@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const confirm = useConfirm(); // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). const [selectedId, setSelectedId] = useState(() => { - const s = localStorage.getItem("siftlode.playlist"); + const s = localStorage.getItem(LS.playlist); return s ? Number(s) : null; }); useEffect(() => { - if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); + if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId)); }, [selectedId]); const selectedRef = useRef(null); const scrolledRef = useRef(false); // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => { - try { - const s = localStorage.getItem("siftlode.plSort"); - if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) }; - } catch { - /* ignore */ - } - return PL_SORT_DEFAULT; - }); + const [plSort, setPlSort] = useState(() => readMerged(LS.plSort, PL_SORT_DEFAULT)); useEffect(() => { - localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); + writeJSON(LS.plSort, plSort); }, [plSort]); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 156225f..cd72d7b 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; +import { LS, usePersistedState } from "../lib/storage"; import Avatar from "./Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -38,14 +39,6 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "account", icon: User }, ]; -// Persist the active tab so a reload (F5) keeps the user where they were instead of -// snapping back to "appearance". -const SETTINGS_TAB_KEY = "siftlode.settingsTab"; -function loadSettingsTab(): TabId { - const v = localStorage.getItem(SETTINGS_TAB_KEY); - return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance"; -} - // Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps // the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is // shown by the Header; the tab rail stays as in-page sub-navigation. @@ -59,11 +52,11 @@ export default function SettingsPanel({ onOpenWizard: () => void; }) { const { t } = useTranslation(); - const [tab, setTabState] = useState(loadSettingsTab); - const setTab = (id: TabId) => { - setTabState(id); - localStorage.setItem(SETTINGS_TAB_KEY, id); - }; + const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance"); + // Clamp at render so a stale/removed tab id falls back to Appearance. + const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) + ? (tabRaw as TabId) + : "appearance"; return (
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 97a6109..ec14479 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -5,10 +5,10 @@ import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; +import { LS, usePersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; const RANGES = [7, 30, 90] as const; -const STATS_TAB_KEY = "siftlode.statsTab"; type StatsTab = "overview" | "system"; // Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions); @@ -17,14 +17,9 @@ type StatsTab = "overview" | "system"; export default function Stats({ me }: { me: Me }) { const { t } = useTranslation(); const isAdmin = me.role === "admin"; - const [tab, setTabState] = useState(() => { - const v = localStorage.getItem(STATS_TAB_KEY); - return v === "system" && isAdmin ? "system" : "overview"; - }); - const setTab = (id: StatsTab) => { - setTabState(id); - localStorage.setItem(STATS_TAB_KEY, id); - }; + const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview"); + // Clamp at render: only admins may land on the System tab (covers a stale stored value). + const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; return (
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 7871c3d..42b1387 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { usePersistedState } from "../lib/storage"; // Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…). // The active tab is persisted to localStorage so a reload (F5) keeps the user where they were, @@ -10,16 +10,9 @@ export interface TabDef { badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined } -/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at - * render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */ -export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] { - const [tab, setTabState] = useState(() => localStorage.getItem(storageKey) ?? fallback); - const setTab = (id: string) => { - setTabState(id); - localStorage.setItem(storageKey, id); - }; - return [tab, setTab]; -} +/** Persisted active-tab state — the canonical helper now lives in lib/storage as + * `usePersistedState`; re-exported here under its original name for existing call sites. */ +export const usePersistedTab = usePersistedState; export default function Tabs({ tabs, diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 50397b6..6cb3abc 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,5 +1,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; +import { LS } from "../lib/storage"; // Endonyms (shown as-is regardless of the active language — the convention for language pickers). export const LANGUAGES = [ @@ -9,7 +10,7 @@ export const LANGUAGES = [ ] as const; export type LangCode = (typeof LANGUAGES)[number]["code"]; const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; -export const LANG_KEY = "siftlode.lang"; +export const LANG_KEY = LS.lang; // Auto-load every locale file (locales//.json) and merge each under one // `translation` namespace, so components use t("area.key") and adding an area needs no wiring. diff --git a/frontend/src/lib/errorDialog.ts b/frontend/src/lib/errorDialog.ts index 63c3c66..70f5a4c 100644 --- a/frontend/src/lib/errorDialog.ts +++ b/frontend/src/lib/errorDialog.ts @@ -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(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; diff --git a/frontend/src/lib/hints.ts b/frontend/src/lib/hints.ts index cb3b274..b4ecaae 100644 --- a/frontend/src/lib/hints.ts +++ b/frontend/src/lib/hints.ts @@ -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(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); - }; } diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 9e6ff0c..80aa84b 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -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): 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(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 */ } diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts index 496e915..26822c3 100644 --- a/frontend/src/lib/onboarding.ts +++ b/frontend/src/lib/onboarding.ts @@ -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; diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index fe98f9c..0b7508f 100644 --- a/frontend/src/lib/sidebarLayout.ts +++ b/frontend/src/lib/sidebarLayout.ts @@ -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(LS.sidebarLayout, {})); } export function saveLayoutLocal(l: SidebarLayout): void { - localStorage.setItem(KEY, JSON.stringify(l)); + writeJSON(LS.sidebarLayout, l); } diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index ccfdfca..e841652 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -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); } diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index caa07a4..e7ee557 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -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;