- Own folders are made, renamed and deleted from the rail (right click, long press, Shift+F10) or the "…" beside an open folder's name; any folder's retention is a submenu (7/30/90 days, until deleted). Deleting confirms where its files go. - List or grid per folder, sharing one sort; the grid is the one media card. - A file dropped anywhere on the page opens Upload with it, bound for the folder on screen. - A link (?item=) or the upload notice opens Files on the file: its page turned to, the keyboard on it, a ring round it. - An in-place name field left by Enter or Escape hands the focus back to what replaced it (the row, the entry, the "…") instead of dropping it on <body>. - The table's saved page size is per account.
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
import { useCallback, useState } from "react";
|
||
import { activeAccountId } from "./activeAccount";
|
||
|
||
// 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",
|
||
plexLayout: "siftlode.plexLayout",
|
||
playlistsLayout: "siftlode.playlistsLayout",
|
||
playlistsCollapsed: "siftlode.playlistsCollapsed",
|
||
filesLayout: "siftlode.filesLayout",
|
||
filesCollapsed: "siftlode.filesCollapsed",
|
||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||
filesView: "siftlode.filesView",
|
||
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
|
||
filesTable: "siftlode.filesTable",
|
||
// List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the
|
||
// grid's cards per page — per-account mirrors of the server prefs (the last is local only).
|
||
filesLayouts: "siftlode.filesLayouts",
|
||
filesSort: "siftlode.filesSort",
|
||
filesCardSize: "siftlode.filesCardSize",
|
||
hints: "siftlode.hints",
|
||
lang: "siftlode.lang",
|
||
filters: "siftlode.filters",
|
||
page: "siftlode.page",
|
||
// Start every session on the Home surface rather than on the module you happened to leave
|
||
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
|
||
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
|
||
// account is pinned — so the landing is decided right before the first paint, never corrected after.
|
||
startOnHome: "siftlode.startOnHome",
|
||
perfMode: "siftlode.perfMode",
|
||
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
|
||
// from only when the server has never stored the key.
|
||
feedView: "siftlode.feedView",
|
||
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
|
||
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
|
||
// the old single-string `channelFilter` sync chip.
|
||
channelsFilterMap: "siftlode.channelsFilterMap",
|
||
// DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into
|
||
// `channelsFilterMap`, then inert. Do not write.
|
||
legacyChannelFilter: "siftlode.channelFilter",
|
||
channelsView: "siftlode.channelsView",
|
||
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
||
channelLayout: "siftlode.channelLayout",
|
||
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
||
channelSort: "siftlode.channelSort",
|
||
channelShow: "siftlode.channelShow",
|
||
channelDiscoverySort: "siftlode.channelDiscoverySort",
|
||
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
||
channelCardSize: "siftlode.channelCardSize",
|
||
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
|
||
channelsTable: "siftlode.channelsTable",
|
||
// Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
|
||
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
||
channelNames: "siftlode.channelNames",
|
||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
||
// statsTab below.
|
||
settingsTab: "siftlode.settingsTab",
|
||
configTab: "siftlode.configTab",
|
||
auditTable: "siftlode.auditTable",
|
||
statsTab: "siftlode.statsTab",
|
||
adminUsersTab: "siftlode.adminUsersTab",
|
||
filterCollapsed: "siftlode.filterCollapsed",
|
||
playlist: "siftlode.playlist",
|
||
plSort: "siftlode.plSort",
|
||
plexLibrary: "siftlode.plexLibrary",
|
||
plexShow: "siftlode.plexShow",
|
||
plexSort: "siftlode.plexSort",
|
||
plexFilters: "siftlode.plexFilters",
|
||
plexQ: "siftlode.plexQ",
|
||
plexField: "siftlode.plexField",
|
||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
||
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
||
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate
|
||
// flag). A YT.Player instance is created per video and starts at full volume, so without this
|
||
// every next video is loud again (same idea as plexPlayerPrefs).
|
||
ytPlayerPrefs: "siftlode.ytPlayerPrefs",
|
||
notifHistory: "siftlode.notifications",
|
||
notifSettings: "siftlode.notifSettings",
|
||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||
seenVersion: "siftlode.seenVersion",
|
||
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
|
||
// that page runs outside the app, with no account at all.
|
||
watchRepeat: "siftlode.watchRepeat",
|
||
// Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same
|
||
// reason as above: the viewer has no account to keep it in.
|
||
watchResume: "siftlode.watchResume",
|
||
} as const;
|
||
|
||
// --- per-account scoping -----------------------------------------------------------------
|
||
// The active-account primitive (activeAccountId) lives in ./activeAccount
|
||
// so it can be shared without a cycle; the helpers below key localStorage on it.
|
||
|
||
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
|
||
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
|
||
* into one browser — a bare shared key leaks one account's state into another (and, with per-tab
|
||
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
|
||
* null when no account is 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 = activeAccountId(),
|
||
): string | null {
|
||
return id != null ? `${base}.${id}` : null;
|
||
}
|
||
|
||
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
|
||
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
|
||
export function readAccount<T>(base: string, fallback: T): T {
|
||
const k = accountKey(base);
|
||
return k ? readJSON(k, fallback) : fallback;
|
||
}
|
||
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
|
||
const k = accountKey(base);
|
||
return k ? readMerged(k, defaults) : { ...defaults };
|
||
}
|
||
export function writeAccount(base: string, value: unknown): void {
|
||
const k = accountKey(base);
|
||
if (k) writeJSON(k, value);
|
||
}
|
||
export function getAccountRaw(base: string): string | null {
|
||
const k = accountKey(base);
|
||
return k ? localStorage.getItem(k) : null;
|
||
}
|
||
export function setAccountRaw(base: string, value: string): void {
|
||
const k = accountKey(base);
|
||
if (k) {
|
||
try {
|
||
localStorage.setItem(k, value);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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. Module-private: its only caller is
|
||
* `readAccountMerged` above (exporting it makes knip flag it as an unused 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, scoped to the current account (see
|
||
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
|
||
* same browser. Survives 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. Read once, at
|
||
* mount: BootGate mounts every consumer after the account is pinned. */
|
||
export function useAccountPersistedState(
|
||
base: string,
|
||
fallback = "",
|
||
): [string, (value: string) => void] {
|
||
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
||
// Stable setter (base is stable) so a provider can build a stable action on top of it — e.g.
|
||
// ChannelsProvider's setView, which must not change identity on every tab switch.
|
||
const set = useCallback(
|
||
(next: string) => {
|
||
setValue(next);
|
||
setAccountRaw(base, next);
|
||
},
|
||
[base],
|
||
);
|
||
return [value, set];
|
||
}
|
||
|
||
// --- a page's sub-tab, per browser tab ---------------------------------------------------------
|
||
|
||
// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser
|
||
// tab only — sessionStorage, account-scoped: it survives a reload and a trip to another page, and a new
|
||
// tab opens the page on its first sub-tab (the owner's call: a position remembered forever in
|
||
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home).
|
||
|
||
/** The sub-tab stored for this browser tab, or null. */
|
||
function getTabRaw(base: string): string | null {
|
||
const k = accountKey(base);
|
||
try {
|
||
return k ? sessionStorage.getItem(k) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */
|
||
export function setTabRaw(base: string, value: string): void {
|
||
const k = accountKey(base);
|
||
if (!k) return;
|
||
try {
|
||
sessionStorage.setItem(k, value);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */
|
||
export function useTabPersistedState(
|
||
base: string,
|
||
fallback = "",
|
||
): [string, (value: string) => void] {
|
||
const [value, setValue] = useState<string>(() => {
|
||
// The position used to live in localStorage; a value left there would never be read again.
|
||
const old = accountKey(base);
|
||
if (old) {
|
||
try {
|
||
localStorage.removeItem(old);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return getTabRaw(base) ?? fallback;
|
||
});
|
||
const set = useCallback(
|
||
(next: string) => {
|
||
setValue(next);
|
||
setTabRaw(base, next);
|
||
},
|
||
[base],
|
||
);
|
||
return [value, set];
|
||
}
|
||
|
||
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
|
||
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
|
||
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
|
||
export function useAccountPersistedObject<T extends object>(
|
||
base: string,
|
||
defaults: T,
|
||
): [T, (patch: Partial<T>) => void] {
|
||
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
||
const patch = useCallback(
|
||
(p: Partial<T>) => {
|
||
setValue((prev) => {
|
||
const next = { ...prev, ...p };
|
||
writeAccount(base, next);
|
||
return next;
|
||
});
|
||
},
|
||
[base],
|
||
);
|
||
return [value, patch];
|
||
}
|