feat(nav): tabs, list views and name searches are named in the address

Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected
while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the
Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf)
reopen from the address; the searches are written once the typing pauses.
This commit is contained in:
2026-10-26 01:13:39 +01:00
parent 91d36b3357
commit d4f24de4ba
34 changed files with 728 additions and 130 deletions
+22 -7
View File
@@ -7,11 +7,14 @@ import {
hasPlexParams,
plexParamsToState,
plexParamUpdates,
plexSearchFrom,
plexSearchParamUpdates,
type PlexUrlState,
} from "../../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
@@ -28,7 +31,7 @@ import {
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
// scope / show / sort / filters / q / field are per-account persisted (localStorage) and seeded from it at mount
// (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
@@ -132,9 +135,13 @@ export function PlexProvider({ children }: { children: ReactNode }) {
return EMPTY_PLEX_FILTERS;
}
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(readPlexQ);
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
// Remembered per account (a trip back through the rail finds it), and named in the address: a link's
// search wins and is remembered too.
const [urlSearch] = useState(() => plexSearchFrom(new URLSearchParams(window.location.search)));
const [q, setQState] = useState<string>(() => urlSearch.q ?? readPlexQ());
const [field, setFieldState] = useState<PlexSearchField>(
() => urlSearch.field ?? readPlexField(),
);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
// PlexBrowse consumes it into an info/show history subview then clears it.
@@ -153,6 +160,10 @@ export function PlexProvider({ children }: { children: ReactNode }) {
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
}, [urlSeed]);
useEffect(() => {
if (urlSearch.q != null) setAccountRaw(LS.plexQ, urlSearch.q);
if (urlSearch.field != null) setAccountRaw(LS.plexField, urlSearch.field);
}, [urlSearch]);
const setScope = useCallback((v: string) => {
logFilterChange("plex", { scope: v });
@@ -197,10 +208,14 @@ export function PlexProvider({ children }: { children: ReactNode }) {
// Mirror the content state (scope + sort + watch-state + filters) into the address bar under the
// namespaced keys so a Plex-browse view is shareable. Gated on urlPage===plex: off the Plex page (or
// under a channel page) the keys are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
// search term is deliberately transient-per-view and not part of the shared four-key contract.
// under a channel page) the keys are cleared (localStorage still holds the state; it re-mirrors on
// return). The search beside them, once the typing pauses: one address-bar write per keystroke is
// churn a browser throttles.
const nav = useNavigation();
useUrlMirror(plexParamUpdates(urlPage(nav) === "plex" ? { scope, sort, show, filters } : null));
const shown = urlPage(nav) === "plex";
useUrlMirror(plexParamUpdates(shown ? { scope, sort, show, filters } : null));
const typed = useDebounced(q, 300);
useUrlMirror(plexSearchParamUpdates(shown ? { q: typed, field } : null));
return (
<PlexActionsContext.Provider value={actions}>
+2 -2
View File
@@ -1,7 +1,7 @@
import { Clapperboard } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { PLEX_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState";
import { PLEX_KEYS, PLEX_SEARCH_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState";
import { PlexStore } from "./PlexStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -18,5 +18,5 @@ export const plexModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
maxWidth: "100rem",
Store: PlexStore,
urlKeys: [...PLEX_KEYS, PLEX_SUB_KEY],
urlKeys: [...PLEX_KEYS, ...PLEX_SEARCH_KEYS, PLEX_SUB_KEY],
};