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
@@ -15,6 +15,7 @@ import { useSortState } from "../../lib/useSortState";
import { usePref } from "../../lib/usePref";
import { useFilterState } from "../../lib/useFilterState";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { rememberNavPosition } from "../../lib/history";
import {
channelsParamsToState,
@@ -43,7 +44,8 @@ import {
//
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// per-account persisted (localStorage); the search lives only in the address (below); focus and the
// reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
//
// The contexts, their value types and the hooks live in channelsContext.ts.
@@ -93,6 +95,7 @@ function readChannelsUrlSeed(): ChannelsUrlState | null {
view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(),
search: "",
});
}
@@ -101,7 +104,11 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const { page } = nav;
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const seededRef = useRef(false);
const [search, setSearchState] = useState(() => urlSeed?.search ?? "");
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
@@ -122,10 +129,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [focusNameRaw, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const seededRef = useRef(false);
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
@@ -247,12 +250,17 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
],
);
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or under a channel
// page) the keys are cleared
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
// deliberately transient rail-search, like the feed's and plex's.
useUrlMirror(channelsParamUpdates(urlPage(nav) === "channels" ? { view, sort, filters } : null));
// Mirror the content state (tab + sort + column filters + the name search) into the address bar under
// the namespaced keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or
// under a channel page) the keys are cleared (localStorage still holds the rest; it re-mirrors on
// return; the search is this visit's). The search is written once the typing pauses: one address-bar
// write per keystroke is history churn a browser throttles.
const typed = useDebounced(search, 300);
useUrlMirror(
channelsParamUpdates(
urlPage(nav) === "channels" ? { view, sort, filters, search: typed } : null,
),
);
return (
<ChannelsActionsContext.Provider value={actions}>