Files
siftlode/frontend/src/lib/storage.ts
T
peter f9681deecf feat(plex): field-scoped search (S2) + instant search typeahead
Add a search scope-pill to the Plex header search box (GitHub-style, one
field-scope at a time) and an instant typeahead of the top matches below it.

Backend (unified_library):
- New `field` param: all | title | people | summary | episode. `all` is
  byte-identical to the pre-scope behaviour (untouched weighted search_vector);
  a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a
  per-column tsvector recheck, so scoped hits are a strict subset of Everywhere.
  Per-field expressions mirror the search_vector Computed def (same TS config,
  same left(summary,1000) truncation).
- `episode` scope empties the movie/show grid and title-only-matches the grouped
  Episodes section; the title/people/summary scopes suppress that section.
- Unknown field falls back to `all`. New DB-lane characterization tests
  (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is
  identical to today, episode empties the grid and needs shows in scope.

Frontend:
- PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing
  ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/
  keydown slots so other modules' search boxes are untouched.
- Episode scope offered only when shows are in scope (movie-only hides it),
  healed at the point of use like the sort state.
- Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the
  sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc),
  and jumps straight to a movie's info page or a show's drill-down via a new
  provider jumpTo hand-off (mirrors the playlistOpen pattern).
- `field` persisted per-account; en/hu labels added.
2026-08-16 08:05:18 +02:00

198 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
import { useAccountRehydrate } from "./useAccountRehydrate";
// 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",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
// Mirror of the default saved view's filters, so it can be applied synchronously on load
// (before the async saved-views query resolves). Kept in sync by SavedViewsWidget.
defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
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",
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",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
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",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId / useActiveAccountId / notify) 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. */
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. Consumers
* usually mount only once signed in (account known), but re-read on the account-pin transition too so
* one that mounts before the pin (a fresh tab's first paint) still restores. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
// Stable setter (base + markDirty are both 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) => {
markDirty();
setValue(next);
setAccountRaw(base, next);
},
[base, markDirty],
);
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 markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
const patch = useCallback(
(p: Partial<T>) => {
markDirty();
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base, markDirty],
);
return [value, patch];
}