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}>
@@ -55,6 +55,8 @@ import ConfigPanel from "./ConfigPanel";
afterEach(() => {
cleanup();
localStorage.clear();
sessionStorage.clear();
window.history.replaceState(null, "", "/");
});
const renderPanel = () =>
@@ -90,3 +92,24 @@ describe("Configuration sections", () => {
expect(screen.queryByText("config.fields.ka.label")).toBeNull();
});
});
// The URL is the view: the group on screen is named in the address, so a bookmark opens it.
describe("the group in the address", () => {
const cfgtab = () => new URLSearchParams(window.location.search).get("cfgtab");
it("opens on the group a link names, and names the one picked", async () => {
window.history.replaceState(null, "", "/?page=config&cfgtab=c");
renderPanel();
expect(await screen.findByText("config.fields.kc.label")).toBeTruthy();
expect(cfgtab()).toBe("c");
fireEvent.click(screen.getByRole("tab", { name: "config.groups.b" }));
expect(cfgtab()).toBe("b");
});
it("names the group shown when the link's is gone from the registry", async () => {
window.history.replaceState(null, "", "/?page=config&cfgtab=retired");
renderPanel();
expect(await screen.findByText("config.fields.ka.label")).toBeTruthy();
expect(cfgtab()).toBe("a");
});
});
+14 -8
View File
@@ -16,6 +16,8 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import FilterSelect from "../../components/ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar";
import { LS, useTabPersistedState } from "../../lib/storage";
import { CONFIG_TAB_KEY } from "../../lib/configTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button";
import { CheckRow } from "../../components/ui/NativeControls";
@@ -95,7 +97,7 @@ export default function ConfigPanel() {
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = useTabPersistedState(LS.configTab);
const [tab, setTab] = useTabPersistedState(LS.configTab, "", CONFIG_TAB_KEY);
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
@@ -181,6 +183,17 @@ export default function ConfigPanel() {
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
};
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
// zoom, so they are a side list under four headings, as Settings has).
const groupKeys = data?.sections.flatMap((s) => s.groups) ?? [];
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
// The address names the group on screen (always: see SettingsPanel); until the registry is in, the
// one it was opened on.
useUrlMirror({ [CONFIG_TAB_KEY]: (data ? activeGroup : tab) || null });
if (q.isError && !data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -192,13 +205,6 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />;
}
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
// zoom, so they are a side list under four headings, as Settings has).
const groupKeys = data.sections.flatMap((s) => s.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean));
const groupTabs = data.sections.flatMap((s) =>
s.groups.map((g) => ({
+2 -1
View File
@@ -1,6 +1,7 @@
import { SlidersHorizontal } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { CONFIG_TAB_KEY } from "../../lib/configTab";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const configModule: ModuleDef = {
@@ -12,5 +13,5 @@ export const configModule: ModuleDef = {
system: true,
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./ConfigPanel")),
urlKeys: [],
urlKeys: [CONFIG_TAB_KEY],
};
@@ -20,6 +20,7 @@ import {
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type FileFolder, type LibraryFile } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { qk } from "../../lib/queryKeys";
import {
ALL_FILES,
FilesActionsContext,
@@ -172,6 +173,8 @@ const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// The page names its sort in the address: one case's sort must not open the next.
window.history.replaceState(null, "", "/");
mocks.confirm.mockClear();
mocks.keys = [];
mocks.fetching = false;
@@ -513,6 +516,8 @@ describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
fireEvent.click(nameHeader());
expect(screen.getByText("files.countFirst")).toBeTruthy();
cleanup();
// A fresh page: the address names the sort picked above.
window.history.replaceState(null, "", "/");
// A search with no sort of the server's: it answers the newest matches first.
mount(ALL_FILES, vi.fn(), "clip");
@@ -806,6 +811,67 @@ describe("Files · the view", () => {
});
});
// The URL is the view: the sort is named in the address, so a bookmark opens the list in its order.
// Always written, the list's own order too: a page opened without one takes the account's sort.
describe("Files · the sort in the address", () => {
const fsort = () => new URLSearchParams(window.location.search).get("fsort");
const order = () =>
[...document.querySelectorAll<HTMLElement>("[data-nav-index]")].map(
(r) => r.textContent?.match(/Clip \d/)?.[0],
);
const three = () => [
file(2, { ready_at: "2026-10-05T10:00:00Z" }),
file(3, { ready_at: "2026-10-12T10:00:00Z" }),
file(4, { ready_at: "2026-10-03T10:00:00Z" }),
];
// The account's sort, as the server says it.
function mountWithSort(sort: unknown) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { preferences: { filesSort: sort } });
render(
<QueryClientProvider client={qc}>
<FilesActionsContext.Provider
value={{ setSearch: vi.fn(), setView: vi.fn(), openFile: vi.fn(), consumeFocus: vi.fn() }}
>
<FilesStateContext.Provider
value={{ search: "", view: ALL_FILES, focus: null, arrived: null }}
>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
}
it("names the sort picked, and the list's own order", () => {
vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
mocks.files = three();
mocks.folders = [YT];
mount();
expect(fsort()).toBe("none");
fireEvent.click(screen.getByRole("button", { name: /files\.columns\.added/ }));
expect(fsort()).toBe("added:asc");
});
it("opens in the order a link names, over the account's", () => {
mocks.files = three();
mocks.folders = [YT];
window.history.replaceState(null, "", "/?page=files&fsort=added:asc");
mountWithSort({ key: "added", dir: "desc" });
expect(order()).toEqual(["Clip 4", "Clip 2", "Clip 3"]);
expect(fsort()).toBe("added:asc");
});
it("a link to the list's own order is not sorted by the account's", () => {
mocks.files = three();
mocks.folders = [YT];
window.history.replaceState(null, "", "/?page=files&fsort=none");
mountWithSort({ key: "added", dir: "asc" });
expect(order()).not.toEqual(["Clip 4", "Clip 2", "Clip 3"]);
expect(fsort()).toBe("none");
});
});
describe("Files · the grid", () => {
// The layout is a pref, saved to the server; nothing here is about that.
const quiet = () => vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
+14 -1
View File
@@ -19,6 +19,8 @@ import { qk } from "../../lib/queryKeys";
import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager";
import { useDebounced } from "../../lib/useDebounced";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { decodeFilesSort, encodeFilesSort, FILES_SORT_KEY } from "../../lib/filesUrlState";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { JobThumb } from "../../features/downloads/downloadRow";
@@ -207,7 +209,18 @@ export default function Files() {
}, [gone, setView]);
// One sort for both paints: the table's headers and the grid's dropdown move the same state.
const [sort, setSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
const [prefSort, setPrefSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
// A sort the address names wins until one is picked here (which is then the account's again), and
// the address names the sort on screen.
const [linkedSort, setLinkedSort] = useState(() =>
decodeFilesSort(new URLSearchParams(window.location.search).get(FILES_SORT_KEY)),
);
const sort = linkedSort === undefined ? prefSort : linkedSort;
const setSort = (next: SortState) => {
setLinkedSort(undefined);
setPrefSort(next);
};
useUrlMirror({ [FILES_SORT_KEY]: encodeFilesSort(sort) });
const params = listParams(shown);
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
+13 -5
View File
@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState";
import { useDebounced } from "../../lib/useDebounced";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY, FILES_SEARCH_KEY } from "../../lib/filesUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
@@ -23,9 +24,10 @@ import {
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around <App/>.
//
// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// The search lives in the address alone (`?fq=`): a reload or a bookmark opens it searched, and a trip
// back to the page through the rail opens it unsearched. The view is remembered per account and
// mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. A
// `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list).
//
// An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it
@@ -47,7 +49,9 @@ function initialFocus(): FilesFocus | null {
}
export function FilesProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [search, setSearchState] = useState(
() => new URLSearchParams(window.location.search).get(FILES_SEARCH_KEY) ?? "",
);
const [view, setViewState] = useState<FilesView>(initialView);
const [focusRaw, setFocusRaw] = useState<FilesFocus | null>(initialFocus);
const [focusApplied, setFocusApplied] = useState(0);
@@ -90,7 +94,11 @@ export function FilesProvider({ children }: { children: ReactNode }) {
const arrived = page === "files" ? arrivedRaw : null;
const shown = urlPage(nav) === "files";
// Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles);
// an arrival's cleared box at once.
const typed = useDebounced(search.trim(), 300, focusRaw?.token);
useUrlMirror({
[FILES_SEARCH_KEY]: shown && typed ? typed : null,
[FILES_FOLDER_KEY]: shown ? viewParam(view) : null,
[FILES_ITEM_KEY]: shown && focus ? String(focus.id) : null,
});
@@ -183,6 +183,8 @@ afterEach(() => {
cleanup();
vi.restoreAllMocks();
document.body.replaceChildren();
// The page names its view in the address: one case's sort must not open the next.
window.history.replaceState(null, "", "/");
});
describe("Playlists — the row the player is launched with", () => {
@@ -794,3 +796,51 @@ describe("Playlists · the page's background menu", () => {
expect(rows).not.toContain("playlists.revert");
});
});
// The URL is the view: the open playlist's sort, filters and grouping are named in the address, so a
// reload or a bookmark opens the list arranged as it was left (they lived in the page's memory alone).
describe("Playlists · the view in the address", () => {
const param = (k: string) => new URLSearchParams(window.location.search).get(k);
const titles = () =>
screen
.getAllByRole("button", { name: /^card\.play — / })
.map((b) => b.getAttribute("aria-label")!.replace("card.play — ", ""));
it("names the sort and the grouping picked", async () => {
mount({ 1: [vid("A", 0), vid("B", 0)] });
await screen.findByText("Video A");
expect(param("plsort")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ }));
await waitFor(() => expect(param("plsort")).toBe("title:asc"));
fireEvent.click(screen.getByText("playlists.groupByChannel"));
await waitFor(() => expect(param("plgroup")).toBe("1"));
});
it("opens on the view a link names", async () => {
window.history.replaceState(
null,
"",
`/?page=playlists&pl=1&plsort=title:desc&plgroup=1&plf=${encodeURIComponent('{"title":"Video"}')}`,
);
mount({ 1: [vid("A", 0), vid("C", 0), vid("B", 0)] });
await screen.findByText("Video A");
expect(titles()).toEqual(["Video C", "Video B", "Video A"]);
expect(screen.getByRole("checkbox", { name: "playlists.groupByChannel" })).toHaveProperty(
"checked",
true,
);
expect(param("plf")).toBe('{"title":"Video"}');
});
it("starts over on another playlist, and its address with it", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&plsort=title:desc&plgroup=1");
const { rerender } = mount({ 1: [vid("A", 0), vid("B", 0)], 2: [vid("D", 0), vid("E", 0)] });
await screen.findByText("Video A");
h.selectedId = 2;
rerender();
await screen.findByText("Video D");
expect(titles()).toEqual(["Video D", "Video E"]);
await waitFor(() => expect(param("plsort")).toBeNull());
expect(param("plgroup")).toBeNull();
});
});
+19 -9
View File
@@ -41,6 +41,13 @@ import { playlistName } from "../../lib/playlistName";
import { reinsertAt } from "../../lib/reinsert";
import { useUndoable } from "../../lib/useUndoable";
import { cycleSort, type SortState } from "../../lib/columnSort";
import { useUrlMirror } from "../../lib/useUrlMirror";
import {
isStoredSort,
PLAYLIST_STORED_SORT,
playlistViewFrom,
playlistViewParamUpdates,
} from "../../lib/playlistsUrlState";
import { formatRuntime } from "../../lib/format";
import { applyView, sameOrder } from "../../lib/playlistView";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -95,11 +102,8 @@ import PlaylistsHeader from "./PlaylistsHeader";
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
// its own filter/sort/page state and the stored order needs a single owner.
// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
const STORED_SORT: SortState = { key: "pos", dir: "asc" };
const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";
// The neutral view ("#" ascending, the stored order) lives beside the address-bar codec of the view.
const STORED_SORT = PLAYLIST_STORED_SORT;
const idsKey = (items: Video[]) =>
items
@@ -232,10 +236,13 @@ function PlaylistDetail() {
},
});
const items = order.value;
// VIEW state — never written to the server on its own.
const [viewSort, setViewSort] = useState<SortState>(STORED_SORT);
const [filters, setFilters] = useState<FilterMap>({});
const [groupBy, setGroupBy] = useState(false);
// VIEW state — never written to the server on its own. The address names it, so a reload or a
// bookmark opens the playlist sorted, filtered and grouped as it was left (the view it opens on is
// the address's; a switch to another playlist starts over, below).
const [opened] = useState(() => playlistViewFrom(new URLSearchParams(window.location.search)));
const [viewSort, setViewSort] = useState<SortState>(opened.sort);
const [filters, setFilters] = useState<FilterMap>(opened.filters);
const [groupBy, setGroupBy] = useState(opened.groupBy);
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
// the actual set of items changes (different playlist, or an add/remove).
@@ -354,6 +361,9 @@ function PlaylistDetail() {
setFilters({});
setGroupBy(false);
}
useUrlMirror(
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
);
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import { readSelectedId } from "./playlistsInit";
@@ -17,15 +18,17 @@ import {
// FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed
// in main.tsx around <App/>).
//
// The `search` term is ephemeral (resets on reload, like the other list filters); the selected
// playlist is per-account persisted (LS.playlist) so a reload returns you to it. Neither is
// server-synced, and an account switch does a full reload, so init-from-storage is all the
// The `search` term lives in the address alone (`?plq=`, a reload or a bookmark keeps it, a trip back
// through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so
// a reload returns you to it, and named in the address too. Neither is server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters).
//
// The contexts + hooks live in playlistsContext.ts.
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [search, setSearchState] = useState(
() => new URLSearchParams(window.location.search).get("plq") ?? "",
);
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
const setSearch = useCallback((q: string) => setSearchState(q), []);
@@ -37,9 +40,13 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere,
// and under a channel page (urlPage; the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
// params intact.
// The search once the typing pauses: one address-bar write per keystroke is churn a browser throttles.
const nav = useNavigation();
const shown = urlPage(nav) === "playlists";
const typed = useDebounced(search.trim(), 300);
useUrlMirror({
pl: urlPage(nav) === "playlists" && selectedId != null ? String(selectedId) : null,
pl: shown && selectedId != null ? String(selectedId) : null,
plq: shown && typed ? typed : null,
});
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
+2 -1
View File
@@ -1,6 +1,7 @@
import { ListVideo } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { PLAYLISTS_KEYS } from "../../lib/playlistsUrlState";
import { PlaylistsProvider } from "./PlaylistsProvider";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -17,5 +18,5 @@ export const playlistsModule: ModuleDef = {
maxWidth: "64rem",
Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: ["pl"],
urlKeys: PLAYLISTS_KEYS,
};
+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],
};
@@ -26,6 +26,8 @@ import { api, HttpError, type Me } from "../../lib/api";
import { displayName, roleKey } from "../../lib/api/account";
import { notifyActionError } from "../../lib/actionErrors";
import { LS, useTabPersistedState } from "../../lib/storage";
import { SETTINGS_TAB_KEY } from "../../lib/settingsTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import Avatar from "../../components/ui/Avatar";
import { notify, type NotifSettings } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
@@ -70,12 +72,15 @@ export default function SettingsPanel() {
const prefs = usePrefs();
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance", SETTINGS_TAB_KEY);
const panel = useTabPanel();
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId)
: "appearance";
// The address names the tab on screen, so a bookmark opens it (always written: without it, the page
// opens on the tab this browser tab last showed).
useUrlMirror({ [SETTINGS_TAB_KEY]: tab });
return (
<div className="p-4 max-w-3xl w-full mx-auto">
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
//
// The URL is the view: Settings' tab is named in the address, so a reload or a bookmark opens the tab
// it showed (it lived in sessionStorage alone: a bookmark opened Appearance). And a notice's link to a
// tab, followed while Settings is already on screen, turns to it — the navigation to the page it is on
// is a no-op, so the pre-selection used to wait for the next visit.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ id: 1, email: "a@x", role: "user" }) }));
vi.mock("../../features/prefs/prefsContext", () => ({ usePrefs: () => ({}) }));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: () => {} }),
}));
// The tabs' own content is not what is under test.
vi.mock("./ApiTokens", () => ({ default: () => <p>tokens-tab</p> }));
vi.mock("./DiagnosticsSettings", () => ({ default: () => <p>diagnostics-tab</p> }));
import SettingsPanel from "./SettingsPanel";
import { focusSettingsTab } from "../../lib/settingsTab";
import { claimLocalData } from "../../lib/storage";
beforeEach(() => {
claimLocalData(1);
});
afterEach(() => {
cleanup();
sessionStorage.clear();
localStorage.clear();
window.history.replaceState(null, "", "/");
});
const search = () => new URLSearchParams(window.location.search);
const mount = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<SettingsPanel />
</QueryClientProvider>,
);
describe("Settings' tab in the address", () => {
it("opens on the tab a link names, over the one this browser tab last showed", () => {
focusSettingsTab("diagnostics");
window.history.replaceState(null, "", "/?page=settings&stab=tokens");
mount();
expect(screen.getByText("tokens-tab")).toBeTruthy();
expect(search().get("stab")).toBe("tokens");
});
it("names the tab picked", () => {
window.history.replaceState(null, "", "/?page=settings&stab=tokens");
mount();
fireEvent.click(screen.getByTestId("settings-tab-diagnostics"));
expect(screen.getByText("diagnostics-tab")).toBeTruthy();
expect(search().get("stab")).toBe("diagnostics");
});
it("turns to a tab pre-selected while the page is on screen", () => {
window.history.replaceState(null, "", "/?page=settings&stab=tokens");
mount();
act(() => focusSettingsTab("diagnostics"));
expect(screen.getByText("diagnostics-tab")).toBeTruthy();
expect(search().get("stab")).toBe("diagnostics");
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { Settings } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { SETTINGS_TAB_KEY } from "../../lib/settingsTab";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const settingsModule: ModuleDef = {
@@ -10,5 +11,5 @@ export const settingsModule: ModuleDef = {
titleKey: "settings.title",
icon: Settings,
Page: lazyWithPreload(() => import("./SettingsPanel")),
urlKeys: [],
urlKeys: [SETTINGS_TAB_KEY],
};
@@ -35,6 +35,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
vi.mock("../../lib/errorDialog", () => ({ reportError: vi.fn() }));
import AdminUsers from "./AdminUsers";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { api, type AdminUserRow } from "../../lib/api";
import { relativeTime } from "../../lib/format";
import i18n from "../../i18n";
@@ -190,3 +191,32 @@ describe("a user action that fails", () => {
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
});
});
// The URL is the view: the tab on screen is named in the address, so a bookmark opens it.
describe("the tab in the address", () => {
afterEach(() => window.history.replaceState(null, "", "/"));
const utab = () => new URLSearchParams(window.location.search).get("utab");
const selected = () =>
screen.getAllByRole("tab").find((el) => el.getAttribute("aria-selected") === "true")
?.textContent;
it("opens on the tab a link names, and names the one picked", () => {
window.history.replaceState(null, "", "/?page=users&utab=storage");
vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
// The tab bar portals into the shell's toolbar band.
const slot = document.body.appendChild(document.createElement("div"));
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PageToolbarSlot.Provider value={slot}>
<AdminUsers />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
expect(selected()).toBe("users.tabs.storage");
expect(utab()).toBe("storage");
fireEvent.click(screen.getByRole("tab", { name: "users.tabs.roles" }));
expect(utab()).toBe("roles");
});
});
+5 -2
View File
@@ -27,7 +27,7 @@ import { displayName } from "../../lib/api/account";
import { notify } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../../lib/adminUsersTab";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY } from "../../lib/adminUsersTab";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
@@ -42,6 +42,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { useTabPersistedState } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { PageToolbar } from "../../components/ui/PageShell";
import { useMe } from "../../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../../lib/actionErrors";
@@ -71,7 +72,7 @@ const ROW_OPEN =
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles", USERS_TAB_URL_KEY);
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
@@ -87,6 +88,8 @@ export default function AdminUsers() {
{ id: "storage", label: t("users.tabs.storage") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
// The address names the tab on screen (always: see SettingsPanel).
useUrlMirror({ [USERS_TAB_URL_KEY]: active });
return (
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Users } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { USERS_TAB_URL_KEY } from "../../lib/adminUsersTab";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const usersModule: ModuleDef = {
@@ -13,5 +14,5 @@ export const usersModule: ModuleDef = {
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./AdminUsers")),
maxWidth: "48rem",
urlKeys: [],
urlKeys: [USERS_TAB_URL_KEY],
};