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
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "./lib/sidebarLayout";
|
} from "./lib/sidebarLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
|
import { LS, readMerged, usePersistedState } from "./lib/storage";
|
||||||
import { useConfirm } from "./components/ConfirmProvider";
|
import { useConfirm } from "./components/ConfirmProvider";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
|
|
@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
show: "unwatched",
|
show: "unwatched",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILTERS_KEY = "siftlode.filters";
|
const FILTERS_KEY = LS.filters;
|
||||||
const PAGE_KEY = "siftlode.page";
|
const PAGE_KEY = LS.page;
|
||||||
const PERF_KEY = "siftlode.perfMode";
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
// 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
|
// 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 {
|
function loadStoredFilters(): FeedFilters {
|
||||||
try {
|
return readMerged(FILTERS_KEY, DEFAULT_FILTERS);
|
||||||
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_FILTERS;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||||
|
|
@ -126,18 +123,13 @@ export default function App() {
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
|
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
||||||
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
|
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
|
||||||
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
|
const channelFilter: ChannelStatusFilter = (
|
||||||
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
|
["needs_full", "fully_synced", "hidden", "all"] as const
|
||||||
? v
|
).includes(channelFilterRaw as ChannelStatusFilter)
|
||||||
: "all";
|
? (channelFilterRaw as ChannelStatusFilter)
|
||||||
});
|
: "all";
|
||||||
// Persist the channel status chip so a reload (F5) keeps it.
|
|
||||||
const setChannelFilter = (f: ChannelStatusFilter) => {
|
|
||||||
setChannelFilterState(f);
|
|
||||||
localStorage.setItem(CHANNEL_FILTER_KEY, f);
|
|
||||||
};
|
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [notesOpen, setNotesOpen] = useState(false);
|
const [notesOpen, setNotesOpen] = useState(false);
|
||||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -145,14 +137,9 @@ export default function App() {
|
||||||
// persisted so navigation intents that target the subscriptions table — the header's
|
// 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
|
// "without full history" link, focus-channel — can force it back to "subscribed" instead
|
||||||
// of dumping the user on whatever tab they last left open.
|
// of dumping the user on whatever tab they last left open.
|
||||||
const CHANNELS_VIEW_KEY = "siftlode.channelsView";
|
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
|
||||||
const [channelsView, setChannelsViewState] = useState<ChannelsView>(() =>
|
const channelsView: ChannelsView =
|
||||||
localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed"
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||||
);
|
|
||||||
const setChannelsView = (v: ChannelsView) => {
|
|
||||||
setChannelsViewState(v);
|
|
||||||
localStorage.setItem(CHANNELS_VIEW_KEY, v);
|
|
||||||
};
|
|
||||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
// 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).
|
// there to see a specific set (the header's "without full history" link).
|
||||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||||
|
|
@ -318,7 +305,7 @@ export default function App() {
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||||
void meQuery.refetch();
|
void meQuery.refetch();
|
||||||
localStorage.setItem("siftlode.settingsTab", "account");
|
localStorage.setItem(LS.settingsTab, "account");
|
||||||
setPage("settings");
|
setPage("settings");
|
||||||
} else {
|
} else {
|
||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||||
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
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
|
// 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
|
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
|
||||||
// on mount, and this page mounts fresh on navigation).
|
// 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 const ADMIN_USERS_ACCESS_TAB = "access";
|
||||||
|
|
||||||
export function focusAccessRequestsTab(): void {
|
export function focusAccessRequestsTab(): void {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
import AvatarImg from "./Avatar";
|
||||||
|
|
@ -48,7 +49,7 @@ export default function NavSidebar({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(
|
const [collapsed, setCollapsed] = useState<boolean>(
|
||||||
() => localStorage.getItem("siftlode.navCollapsed") === "1"
|
() => localStorage.getItem(LS.navCollapsed) === "1"
|
||||||
);
|
);
|
||||||
const [acctOpen, setAcctOpen] = useState(false);
|
const [acctOpen, setAcctOpen] = useState(false);
|
||||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
@ -90,7 +91,7 @@ export default function NavSidebar({
|
||||||
function toggleCollapsed() {
|
function toggleCollapsed() {
|
||||||
setCollapsed((c) => {
|
setCollapsed((c) => {
|
||||||
const next = !c;
|
const next = !c;
|
||||||
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0");
|
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
import { api, type Playlist, type Video } from "../lib/api";
|
import { api, type Playlist, type Video } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { LS, readMerged, writeJSON } from "../lib/storage";
|
||||||
import { useUndoable } from "../lib/useUndoable";
|
import { useUndoable } from "../lib/useUndoable";
|
||||||
import PlayerModal from "./PlayerModal";
|
import PlayerModal from "./PlayerModal";
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
|
|
@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
||||||
const s = localStorage.getItem("siftlode.playlist");
|
const s = localStorage.getItem(LS.playlist);
|
||||||
return s ? Number(s) : null;
|
return s ? Number(s) : null;
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId));
|
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
// How the left playlist rail is ordered (persisted).
|
// How the left playlist rail is ordered (persisted).
|
||||||
const [plSort, setPlSort] = useState<PlSort>(() => {
|
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||||
try {
|
|
||||||
const s = localStorage.getItem("siftlode.plSort");
|
|
||||||
if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) };
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return PL_SORT_DEFAULT;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("siftlode.plSort", JSON.stringify(plSort));
|
writeJSON(LS.plSort, plSort);
|
||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
|
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
|
import { LS, usePersistedState } from "../lib/storage";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
@ -38,14 +39,6 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "account", icon: User },
|
{ 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
|
// 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
|
// 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.
|
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||||
|
|
@ -59,11 +52,11 @@ export default function SettingsPanel({
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tab, setTabState] = useState<TabId>(loadSettingsTab);
|
const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance");
|
||||||
const setTab = (id: TabId) => {
|
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
||||||
setTabState(id);
|
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
||||||
localStorage.setItem(SETTINGS_TAB_KEY, id);
|
? (tabRaw as TabId)
|
||||||
};
|
: "appearance";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { LS, usePersistedState } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
const RANGES = [7, 30, 90] as const;
|
const RANGES = [7, 30, 90] as const;
|
||||||
const STATS_TAB_KEY = "siftlode.statsTab";
|
|
||||||
type StatsTab = "overview" | "system";
|
type StatsTab = "overview" | "system";
|
||||||
|
|
||||||
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
// 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 }) {
|
export default function Stats({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isAdmin = me.role === "admin";
|
const isAdmin = me.role === "admin";
|
||||||
const [tab, setTabState] = useState<StatsTab>(() => {
|
const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview");
|
||||||
const v = localStorage.getItem(STATS_TAB_KEY);
|
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
||||||
return v === "system" && isAdmin ? "system" : "overview";
|
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||||
});
|
|
||||||
const setTab = (id: StatsTab) => {
|
|
||||||
setTabState(id);
|
|
||||||
localStorage.setItem(STATS_TAB_KEY, id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
<div className="p-4 max-w-4xl mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -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…).
|
// 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,
|
// 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
|
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
|
/** Persisted active-tab state — the canonical helper now lives in lib/storage as
|
||||||
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
|
* `usePersistedState`; re-exported here under its original name for existing call sites. */
|
||||||
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
|
export const usePersistedTab = usePersistedState;
|
||||||
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
|
|
||||||
const setTab = (id: string) => {
|
|
||||||
setTabState(id);
|
|
||||||
localStorage.setItem(storageKey, id);
|
|
||||||
};
|
|
||||||
return [tab, setTab];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Tabs({
|
export default function Tabs({
|
||||||
tabs,
|
tabs,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-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).
|
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
|
||||||
export const LANGUAGES = [
|
export const LANGUAGES = [
|
||||||
|
|
@ -9,7 +10,7 @@ export const LANGUAGES = [
|
||||||
] as const;
|
] as const;
|
||||||
export type LangCode = (typeof LANGUAGES)[number]["code"];
|
export type LangCode = (typeof LANGUAGES)[number]["code"];
|
||||||
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
|
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/<lang>/<area>.json) and merge each <area> under one
|
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
|
||||||
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
|
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
|
import { createStore } from "./store";
|
||||||
|
|
||||||
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
|
// 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
|
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
|
||||||
|
|
@ -9,30 +10,18 @@ export interface AppError {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let current: AppError | null = null;
|
const store = createStore<AppError | null>(null);
|
||||||
let listeners: Array<() => void> = [];
|
|
||||||
const emit = () => listeners.forEach((l) => l());
|
|
||||||
|
|
||||||
export function reportError(message?: string, title?: string): void {
|
export function reportError(message?: string, title?: string): void {
|
||||||
current = {
|
store.set({
|
||||||
title: title || i18n.t("errors.title"),
|
title: title || i18n.t("errors.title"),
|
||||||
message: message || i18n.t("errors.generic"),
|
message: message || i18n.t("errors.generic"),
|
||||||
};
|
});
|
||||||
emit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dismissError(): void {
|
export function dismissError(): void {
|
||||||
current = null;
|
store.set(null);
|
||||||
emit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeError(listener: () => void): () => void {
|
export const subscribeError = store.subscribe;
|
||||||
listeners.push(listener);
|
export const getError = store.get;
|
||||||
return () => {
|
|
||||||
listeners = listeners.filter((l) => l !== listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getError(): AppError | null {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,27 @@
|
||||||
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
// 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
|
// 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).
|
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||||
const KEY = "siftlode.hints";
|
import { createStore } from "./store";
|
||||||
|
import { LS } from "./storage";
|
||||||
let enabled = load();
|
|
||||||
let listeners: Array<() => void> = [];
|
|
||||||
|
|
||||||
function load(): boolean {
|
function load(): boolean {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(KEY) !== "0"; // default ON
|
return localStorage.getItem(LS.hints) !== "0"; // default ON
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hintsEnabled(): boolean {
|
const store = createStore<boolean>(load());
|
||||||
return enabled;
|
|
||||||
}
|
export const hintsEnabled = store.get;
|
||||||
|
export const subscribeHints = store.subscribe;
|
||||||
|
|
||||||
export function setHintsEnabled(v: boolean): void {
|
export function setHintsEnabled(v: boolean): void {
|
||||||
enabled = v;
|
store.set(v);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(KEY, v ? "1" : "0");
|
localStorage.setItem(LS.hints, v ? "1" : "0");
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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
|
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||||
// errors). History is persisted to localStorage so it survives reloads; action
|
// errors). History is persisted to localStorage so it survives reloads; action
|
||||||
// callbacks are live-only and dropped on reload.
|
// callbacks are live-only and dropped on reload.
|
||||||
|
import { LS, readJSON, readMerged, writeJSON } from "./storage";
|
||||||
|
|
||||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
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)
|
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_KEY = "siftlode.notifications";
|
const HISTORY_KEY = LS.notifHistory;
|
||||||
const SETTINGS_KEY = "siftlode.notifSettings";
|
const SETTINGS_KEY = LS.notifSettings;
|
||||||
const MAX_HISTORY = 100;
|
const MAX_HISTORY = 100;
|
||||||
const ERROR_TTL = 15000;
|
const ERROR_TTL = 15000;
|
||||||
|
|
||||||
|
|
@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
||||||
let config: NotifSettings = loadSettings();
|
let config: NotifSettings = loadSettings();
|
||||||
|
|
||||||
function loadSettings(): NotifSettings {
|
function loadSettings(): NotifSettings {
|
||||||
try {
|
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotifSettings(): NotifSettings {
|
export function getNotifSettings(): NotifSettings {
|
||||||
|
|
@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings {
|
||||||
|
|
||||||
export function configureNotifications(p: Partial<NotifSettings>): void {
|
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||||
config = { ...config, ...p };
|
config = { ...config, ...p };
|
||||||
try {
|
writeJSON(SETTINGS_KEY, config);
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let audioCtx: AudioContext | null = null;
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
@ -137,7 +130,7 @@ let cachedUnread = 0;
|
||||||
|
|
||||||
function load(): Notification[] {
|
function load(): Notification[] {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
const raw = readJSON<unknown>(HISTORY_KEY, []);
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
// Restored entries are history only. Mark them dismissed so they don't
|
// Restored entries are history only. Mark them dismissed so they don't
|
||||||
// resurrect as active toasts on reload — their auto-dismiss timers aren'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,
|
// 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.
|
// 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);
|
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
writeJSON(HISTORY_KEY, slim);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / serialization errors */
|
/* ignore quota / serialization errors */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
// suppresses the auto-popup on future logins (they
|
// suppresses the auto-popup on future logins (they
|
||||||
// can still reopen it from Settings → Account).
|
// can still reopen it from Settings → Account).
|
||||||
|
|
||||||
export const ONBOARD_ACTIVE = "siftlode.onboarding.active";
|
import { LS } from "./storage";
|
||||||
export const ONBOARD_DISMISSED = "siftlode.onboarding.dismissed";
|
|
||||||
|
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
|
||||||
|
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||||
|
|
||||||
export function shouldAutoOpenOnboarding(me: {
|
export function shouldAutoOpenOnboarding(me: {
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
// `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.
|
// 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 type WidgetId = "date" | "language" | "topic" | "tags";
|
||||||
|
|
||||||
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
|
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
|
||||||
|
|
@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEY = "siftlode.sidebarLayout";
|
|
||||||
|
|
||||||
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
// 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.
|
// (e.g. a widget added in a later version) so nothing silently disappears.
|
||||||
export function normalizeLayout(raw: unknown): SidebarLayout {
|
export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||||
|
|
@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadLayout(): SidebarLayout {
|
export function loadLayout(): SidebarLayout {
|
||||||
try {
|
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
|
||||||
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_LAYOUT;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLayoutLocal(l: SidebarLayout): void {
|
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 Mode = "dark" | "light";
|
||||||
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
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));
|
el.style.setProperty("--font-scale", String(t.fontScale));
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "siftlode.theme";
|
|
||||||
|
|
||||||
export function loadLocalTheme(): ThemePrefs {
|
export function loadLocalTheme(): ThemePrefs {
|
||||||
try {
|
return readMerged(LS.theme, DEFAULT_THEME);
|
||||||
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_THEME;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLocalTheme(t: ThemePrefs): void {
|
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).
|
// 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.
|
// 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_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
||||||
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
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).
|
// 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