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:
@@ -10,8 +10,9 @@ import { MEDIA_CHOSEN } from "./mediaChrome";
|
||||
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
||||
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
||||
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
||||
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats):
|
||||
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every
|
||||
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
|
||||
// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on
|
||||
// the first sub-tab. Not every
|
||||
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
|
||||
// and the player settings start on their first tab each time.
|
||||
//
|
||||
|
||||
@@ -49,20 +49,30 @@ function Probe() {
|
||||
channels: () => channels.setView("hidden"),
|
||||
files: () => files.setView({ kind: "folder", id: 3 }),
|
||||
playlists: () => playlists.setSelectedId(5),
|
||||
"plex search": () => plex.setQ("poirot"),
|
||||
"channels search": () => channels.setSearch("tom"),
|
||||
"files search": () => files.setSearch("notes"),
|
||||
"playlists search": () => playlists.setSearch("jazz"),
|
||||
},
|
||||
};
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// What each page's change above writes, once the page is what the address shows.
|
||||
const WRITTEN: Record<string, [string, string]> = {
|
||||
feed: ["sort", "oldest"],
|
||||
plex: ["pscope", "movie"],
|
||||
channels: ["ctab", "hidden"],
|
||||
files: ["folder", "3"],
|
||||
playlists: ["pl", "5"],
|
||||
// What each change above writes, once its page is what the address shows: [page, key, value]. A
|
||||
// search is written once the typing pauses.
|
||||
const WRITTEN: Record<string, [string, string, string]> = {
|
||||
feed: ["feed", "sort", "oldest"],
|
||||
plex: ["plex", "pscope", "movie"],
|
||||
channels: ["channels", "ctab", "hidden"],
|
||||
files: ["files", "folder", "3"],
|
||||
playlists: ["playlists", "pl", "5"],
|
||||
"plex search": ["plex", "pq", "poirot"],
|
||||
"channels search": ["channels", "cq", "tom"],
|
||||
"files search": ["files", "fq", "notes"],
|
||||
"playlists search": ["playlists", "plq", "jazz"],
|
||||
};
|
||||
const PAUSE = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -75,8 +85,9 @@ afterEach(() => {
|
||||
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
describe.each(Object.keys(WRITTEN))("a channel page over %s", (page) => {
|
||||
describe.each(Object.keys(WRITTEN))("a channel page over %s", (change) => {
|
||||
it("keeps the page's keys off its address, and they come back when it closes", async () => {
|
||||
const [page, key, value] = WRITTEN[change]!;
|
||||
// The page named even for the feed: a bare address opens on Home (the account's default).
|
||||
window.history.replaceState({ sfPage: page }, "", `/?page=${page}`);
|
||||
render(
|
||||
@@ -89,8 +100,8 @@ describe.each(Object.keys(WRITTEN))("a channel page over %s", (page) => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
act(() => probe.current!.nav.openChannel("UC1", "One"));
|
||||
act(() => probe.current!.change[page]!());
|
||||
const [key, value] = WRITTEN[page]!;
|
||||
act(() => probe.current!.change[change]!());
|
||||
await act(PAUSE);
|
||||
expect(search().get(key)).toBeNull();
|
||||
expect(search().get("chan")).toBe("UC1");
|
||||
act(() => probe.current!.nav.closeChannel());
|
||||
|
||||
@@ -2,12 +2,14 @@ import { LS, setTabRaw } from "./storage";
|
||||
|
||||
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
|
||||
// pre-select that tab before navigating there (a notification's "Review" link uses it —
|
||||
// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation).
|
||||
// useTabPersistedState reads the key on mount, and the page on screen hears it).
|
||||
//
|
||||
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
|
||||
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
|
||||
// it back into the main bundle and defeat the code-split.
|
||||
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
||||
// The address-bar key the page mirrors its tab to.
|
||||
export const USERS_TAB_URL_KEY = "utab";
|
||||
const ADMIN_USERS_ACCESS_TAB = "access";
|
||||
|
||||
export function focusAccessRequestsTab(): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Channels-manager content state (active tab + column sort + the column-filter map) round-trips
|
||||
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the plexUrlState
|
||||
// The Channels-manager content state (active tab + column sort + the column-filter map + the name
|
||||
// search) round-trips through namespaced URL keys (ctab/csort/cf/cq) so a manager view is shareable, the plexUrlState
|
||||
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ const RICH_FILTERS: FilterMap = {
|
||||
tags: ["12", "34"],
|
||||
};
|
||||
|
||||
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
|
||||
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {}, search: "" };
|
||||
|
||||
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
||||
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
@@ -32,12 +32,16 @@ function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
}
|
||||
|
||||
describe("channelsParamUpdates", () => {
|
||||
it("writes no search for a blank one", () => {
|
||||
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
|
||||
});
|
||||
|
||||
it("emits nothing for the all-default state", () => {
|
||||
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
|
||||
});
|
||||
|
||||
it("clears every key when passed null (off-manager page)", () => {
|
||||
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
@@ -45,16 +49,16 @@ describe("channelsParamUpdates", () => {
|
||||
view: "hidden",
|
||||
sort: { key: "subscribers", dir: "desc" },
|
||||
filters: { sync: "fully" },
|
||||
search: "tom",
|
||||
});
|
||||
expect(u.ctab).toBe("hidden");
|
||||
expect(u.csort).toBe("subscribers:desc");
|
||||
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
||||
expect(u.cq).toBe("tom");
|
||||
});
|
||||
|
||||
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
||||
expect(
|
||||
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
|
||||
).toBeNull();
|
||||
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,12 +68,14 @@ describe("round-trip", () => {
|
||||
view: "blocked",
|
||||
sort: { key: "priority", dir: "asc" },
|
||||
filters: RICH_FILTERS,
|
||||
search: "jazz",
|
||||
};
|
||||
const params = applyUpdates(channelsParamUpdates(state));
|
||||
const back = channelsParamsToState(params, BASE);
|
||||
expect(back.view).toBe("blocked");
|
||||
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
|
||||
expect(back.filters).toEqual(RICH_FILTERS);
|
||||
expect(back.search).toBe("jazz");
|
||||
// the array shape survives the JSON round-trip, not collapsed to a scalar
|
||||
expect(Array.isArray(back.filters.tags)).toBe(true);
|
||||
});
|
||||
@@ -80,6 +86,7 @@ describe("round-trip", () => {
|
||||
view: "discovery",
|
||||
sort: null,
|
||||
filters: { sync: "needsFull" },
|
||||
search: "",
|
||||
});
|
||||
expect(back.view).toBe("discovery"); // from base, no ctab
|
||||
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
|
||||
@@ -88,7 +95,7 @@ describe("round-trip", () => {
|
||||
|
||||
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
|
||||
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
|
||||
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
|
||||
const back = channelsParamsToState(params, BASE);
|
||||
expect(back.view).toBe("subscribed"); // bogus tab → base
|
||||
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
|
||||
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
// urlState and Plex's plexUrlState. Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
|
||||
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
|
||||
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
||||
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||
// rail search) and stays OUT of the shared contract.
|
||||
// emitted, so URLs stay clean. The name search (`cq`) is part of the view like the rest: a bookmark of a
|
||||
// searched list opens searched.
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
import { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
|
||||
|
||||
// The content state this mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||
// raw provider SortState (healed against live columns only at the point of use), so the user's real
|
||||
@@ -15,43 +16,16 @@ export interface ChannelsUrlState {
|
||||
view: string;
|
||||
sort: SortState;
|
||||
filters: FilterMap;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export const CHANNELS_KEYS = ["ctab", "csort", "cf"] as const;
|
||||
export const CHANNELS_KEYS = ["ctab", "csort", "cf", "cq"] as const;
|
||||
const KEYS = CHANNELS_KEYS;
|
||||
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed.
|
||||
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
||||
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
||||
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
||||
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
|
||||
// no `cf`.
|
||||
function encodeFilters(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||
}
|
||||
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||
}
|
||||
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||
function decodeFilters(raw: string): FilterMap {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: FilterMap = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (typeof val === "string") out[k] = val;
|
||||
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||
out[k] = val as string[];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed. The filter map rides
|
||||
// as one JSON value (lib/filterMapParam).
|
||||
|
||||
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
|
||||
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
|
||||
@@ -60,11 +34,12 @@ function decodeFilters(raw: string): FilterMap {
|
||||
export function channelsParamUpdates(
|
||||
state: ChannelsUrlState | null,
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { ctab: null, csort: null, cf: null };
|
||||
if (!state) return { ctab: null, csort: null, cf: null, cq: null };
|
||||
return {
|
||||
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||
csort: encodeSortParam(state.sort),
|
||||
cf: encodeFilters(state.filters),
|
||||
cf: encodeFilterMap(state.filters),
|
||||
cq: state.search.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +59,7 @@ export function channelsParamsToState(
|
||||
return {
|
||||
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
||||
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
|
||||
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
||||
filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters,
|
||||
search: params.get("cq") ?? base.search,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// The Configuration page's address-bar key: the config group its tab rail shows. Here, beside the other
|
||||
// pages' keys, so the page's definition can declare it without importing the lazy page.
|
||||
export const CONFIG_TAB_KEY = "cfgtab";
|
||||
@@ -1,6 +1,30 @@
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
|
||||
// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in
|
||||
// modules/files/filesContext) and the file it turns to once (`?item=`). Here, beside the other pages'
|
||||
// key lists, so the page's definition can declare them without importing its own folder.
|
||||
// modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the
|
||||
// sort (`?fsort=`). Here, beside the other pages' key lists, so the page's definition can declare them
|
||||
// without importing its own folder.
|
||||
export const FILES_FOLDER_KEY = "folder";
|
||||
export const FILES_ITEM_KEY = "item";
|
||||
export const FILES_KEYS = [FILES_FOLDER_KEY, FILES_ITEM_KEY] as const;
|
||||
export const FILES_SEARCH_KEY = "fq";
|
||||
export const FILES_SORT_KEY = "fsort";
|
||||
export const FILES_KEYS = [
|
||||
FILES_FOLDER_KEY,
|
||||
FILES_ITEM_KEY,
|
||||
FILES_SEARCH_KEY,
|
||||
FILES_SORT_KEY,
|
||||
] as const;
|
||||
|
||||
// The list's own order (no column sort) has a word of its own: the sort is always written, because a
|
||||
// page opened without one takes the account's remembered sort, which may not be the one bookmarked.
|
||||
const UNSORTED = "none";
|
||||
|
||||
export function encodeFilesSort(sort: SortState): string {
|
||||
return encodeSortParam(sort) ?? UNSORTED;
|
||||
}
|
||||
|
||||
/** The sort an address names; `undefined` when it names none (the remembered one applies). */
|
||||
export function decodeFilesSort(raw: string | null): SortState | undefined {
|
||||
if (raw == null) return undefined;
|
||||
return raw === UNSORTED ? null : decodeSortParam(raw);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
|
||||
// A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`).
|
||||
// The map mixes scalar column filters (select, text, the sync chip) with array ones (multi), so it's
|
||||
// carried as a compact JSON blob rather than a nested query string — JSON round-trips both shapes
|
||||
// exactly (and future multi columns for free), and the values are opaque enum/id tokens where a
|
||||
// readable querystring buys little. Empty entries are dropped so a cleared funnel emits nothing.
|
||||
export function encodeFilterMap(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||
}
|
||||
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||
}
|
||||
|
||||
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||
export function decodeFilterMap(raw: string): FilterMap {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: FilterMap = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (typeof val === "string") out[k] = val;
|
||||
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||
out[k] = val as string[];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
|
||||
// The Playlists page's address-bar keys: the playlist shown (`pl`), the rail's name search (`plq`), and
|
||||
// the open playlist's view — its track sort, column filters and grouping (`plsort`/`plf`/`plgroup`).
|
||||
// The playlist is remembered per account too (PlaylistsProvider); the search and the view are
|
||||
// remembered nowhere else (the view starts over on every playlist), so each of those is written only
|
||||
// when it differs from the default: its key's absence IS the default.
|
||||
export const PLAYLISTS_KEYS = ["pl", "plq", "plsort", "plf", "plgroup"] as const;
|
||||
|
||||
export type PlaylistView = { sort: SortState; filters: FilterMap; groupBy: boolean };
|
||||
|
||||
// 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).
|
||||
export const PLAYLIST_STORED_SORT: SortState = { key: "pos", dir: "asc" };
|
||||
|
||||
export function isStoredSort(s: SortState): boolean {
|
||||
return s?.key === PLAYLIST_STORED_SORT?.key && s?.dir === PLAYLIST_STORED_SORT?.dir;
|
||||
}
|
||||
|
||||
/** The address-bar delta for an open playlist's view; `null` clears it (no playlist open). */
|
||||
export function playlistViewParamUpdates(v: PlaylistView | null): Record<string, string | null> {
|
||||
if (!v) return { plsort: null, plf: null, plgroup: null };
|
||||
return {
|
||||
// An unsorted view (a header clicked through to "none") is the stored order too.
|
||||
plsort: v.sort && !isStoredSort(v.sort) ? encodeSortParam(v.sort) : null,
|
||||
plf: encodeFilterMap(v.filters),
|
||||
plgroup: v.groupBy ? "1" : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The view an address names; what it leaves out is the default. */
|
||||
export function playlistViewFrom(params: URLSearchParams): PlaylistView {
|
||||
const sort = params.get("plsort");
|
||||
const filters = params.get("plf");
|
||||
return {
|
||||
sort: (sort != null && decodeSortParam(sort)) || PLAYLIST_STORED_SORT,
|
||||
filters: filters != null ? decodeFilterMap(filters) : {},
|
||||
groupBy: params.get("plgroup") === "1",
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
||||
import { parsePlexField, type PlexSearchField } from "./plexSearch";
|
||||
|
||||
// The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
|
||||
// use), so the user's real preference round-trips through the URL.
|
||||
@@ -20,6 +21,10 @@ export interface PlexUrlState {
|
||||
|
||||
export const PLEX_KEYS = ["pscope", "psort", "pshow", "pf"] as const;
|
||||
const KEYS = PLEX_KEYS;
|
||||
// The header search: its term and the field it searches. Both remembered per account, so both are
|
||||
// always written on the page (an empty term too): a link without them would open with whatever this
|
||||
// browser searched last. The field means something only beside a term.
|
||||
export const PLEX_SEARCH_KEYS = ["pq", "pqf"] as const;
|
||||
// The Plex sub-view's key (show, season, info, person… — modules/plex/plexSubUrl encodes it).
|
||||
export const PLEX_SUB_KEY = "v";
|
||||
const SCOPES = ["movie", "show", "both"];
|
||||
@@ -68,3 +73,24 @@ export function plexParamsToState(params: URLSearchParams, base: PlexUrlState):
|
||||
: base.filters,
|
||||
};
|
||||
}
|
||||
|
||||
export type PlexSearchUrlState = { q: string; field: PlexSearchField };
|
||||
|
||||
/** The address-bar delta for the header search; `null` clears it (off the Plex page). */
|
||||
export function plexSearchParamUpdates(
|
||||
state: PlexSearchUrlState | null,
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { pq: null, pqf: null };
|
||||
const q = state.q.trim();
|
||||
return { pq: q, pqf: q ? state.field : null };
|
||||
}
|
||||
|
||||
/** What of the header search an address names (absent: the remembered value applies). */
|
||||
export function plexSearchFrom(params: URLSearchParams): Partial<PlexSearchUrlState> {
|
||||
const q = params.get("pq");
|
||||
const field = params.get("pqf");
|
||||
return {
|
||||
...(q != null && { q }),
|
||||
...(field != null && { field: parsePlexField(field) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { LS, setTabRaw } from "./storage";
|
||||
|
||||
// The Settings page's tabs, and a helper to pre-select one before navigating there (a notice's link,
|
||||
// a return from Google). useTabPersistedState reads the key when the page mounts, and the page mounts
|
||||
// fresh on navigation. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it.
|
||||
// a return from Google). useTabPersistedState reads the key when the page mounts, and the page on
|
||||
// screen hears it. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it. The
|
||||
// address-bar key the page mirrors its tab to is here too, beside the other pages' keys.
|
||||
export const SETTINGS_TAB_KEY = "stab";
|
||||
|
||||
export type SettingsTabId =
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
|
||||
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
|
||||
@@ -307,10 +307,11 @@ export function useAccountPersistedState(
|
||||
|
||||
// --- 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, the account's (see ownKey): it survives a reload and a trip to another page, and a new
|
||||
// Which sub-tab a page was left on (Settings, Configuration, Users) is kept for THIS browser tab only —
|
||||
// sessionStorage, the account's (see ownKey): 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).
|
||||
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). The
|
||||
// address names the tab too (the page mirrors it): a link or a bookmark that carries one wins.
|
||||
|
||||
/** The sub-tab stored for this browser tab, or null. */
|
||||
function getTabRaw(base: string): string | null {
|
||||
@@ -322,8 +323,7 @@ function getTabRaw(base: string): string | 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 {
|
||||
function writeTabRaw(base: string, value: string): void {
|
||||
const k = ownKey(base);
|
||||
if (!k) return;
|
||||
try {
|
||||
@@ -333,10 +333,23 @@ export function setTabRaw(base: string, value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */
|
||||
// A pre-selection made while the page is already on screen (a notice's link opened over Settings) has
|
||||
// no mount to be read at, and the navigation to the same page is a no-op: the page's hook hears it.
|
||||
const TAB_SELECT_EVENT = "siftlode:tab-select";
|
||||
type TabSelect = { base: string; value: string };
|
||||
|
||||
/** 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 {
|
||||
writeTabRaw(base, value);
|
||||
window.dispatchEvent(new CustomEvent<TabSelect>(TAB_SELECT_EVENT, { detail: { base, value } }));
|
||||
}
|
||||
|
||||
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). `urlKey`: the
|
||||
* address-bar key the page mirrors its tab to — a tab it names is the one the page opens on. */
|
||||
export function useTabPersistedState(
|
||||
base: string,
|
||||
fallback = "",
|
||||
urlKey?: string,
|
||||
): [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.
|
||||
@@ -348,12 +361,27 @@ export function useTabPersistedState(
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const linked = urlKey ? new URLSearchParams(window.location.search).get(urlKey) : null;
|
||||
if (linked) {
|
||||
// Kept for this browser tab too, so a later trip back to the page (its address cleared by the
|
||||
// page switch) still opens it here.
|
||||
writeTabRaw(base, linked);
|
||||
return linked;
|
||||
}
|
||||
return getTabRaw(base) ?? fallback;
|
||||
});
|
||||
useEffect(() => {
|
||||
function onSelect(e: Event) {
|
||||
const { detail } = e as CustomEvent<TabSelect>;
|
||||
if (detail.base === base) setValue(detail.value);
|
||||
}
|
||||
window.addEventListener(TAB_SELECT_EVENT, onSelect);
|
||||
return () => window.removeEventListener(TAB_SELECT_EVENT, onSelect);
|
||||
}, [base]);
|
||||
const set = useCallback(
|
||||
(next: string) => {
|
||||
setValue(next);
|
||||
setTabRaw(base, next);
|
||||
writeTabRaw(base, next);
|
||||
},
|
||||
[base],
|
||||
);
|
||||
|
||||
@@ -41,4 +41,21 @@ describe("a page's sub-tab, per browser tab", () => {
|
||||
const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles"));
|
||||
expect(result.current[0]).toBe("access");
|
||||
});
|
||||
|
||||
it("opens on the tab the address names, and keeps it for this browser tab", () => {
|
||||
setTabRaw("siftlode.settingsTab", "account");
|
||||
window.history.replaceState(null, "", "/?page=settings&stab=tokens");
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTabPersistedState("siftlode.settingsTab", "appearance", "stab"),
|
||||
);
|
||||
expect(result.current[0]).toBe("tokens");
|
||||
unmount();
|
||||
// Back on the page later, through the rail (the page switch cleared its address).
|
||||
window.history.replaceState(null, "", "/?page=settings");
|
||||
expect(
|
||||
renderHook(() => useTabPersistedState("siftlode.settingsTab", "appearance", "stab")).result
|
||||
.current[0],
|
||||
).toBe("tokens");
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The URL is the view: a page's name search is named in the address, so a reload or a bookmark opens
|
||||
// the list searched (Channels, Files and Playlists kept it in memory only, Plex in this browser only).
|
||||
// The real navigation and the real stores, read through their hooks; only the network is absent.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
||||
import { ModuleStores } from "./features/navigation/ModuleStores";
|
||||
import { useChannels } from "./modules/channels/channelsContext";
|
||||
import { useFiles } from "./modules/files/filesContext";
|
||||
import { usePlaylists } from "./modules/playlists/playlistsContext";
|
||||
import { usePlex } from "./modules/plex/plexContext";
|
||||
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage";
|
||||
|
||||
const seen: { current: Record<string, string> } = { current: {} };
|
||||
|
||||
function Probe() {
|
||||
const channels = useChannels();
|
||||
const files = useFiles();
|
||||
const playlists = usePlaylists();
|
||||
const plex = usePlex();
|
||||
useEffect(() => {
|
||||
seen.current = {
|
||||
channels: channels.search,
|
||||
files: files.search,
|
||||
playlists: playlists.search,
|
||||
plex: plex.q,
|
||||
plexField: plex.field,
|
||||
};
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(3);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
function mountAt(url: string, page: string) {
|
||||
window.history.replaceState({ sfPage: page }, "", url);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NavigationProvider>
|
||||
<ModuleStores>
|
||||
<Probe />
|
||||
</ModuleStores>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
describe.each([
|
||||
["channels", "cq", "tom"],
|
||||
["files", "fq", "notes"],
|
||||
["playlists", "plq", "jazz"],
|
||||
["plex", "pq", "poirot"],
|
||||
])("%s's name search", (page, key, value) => {
|
||||
it("opens from the address, and stays in it", async () => {
|
||||
mountAt(`/?page=${page}&${key}=${value}`, page);
|
||||
await waitFor(() => expect(seen.current[page]).toBe(value));
|
||||
expect(search().get(key)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plex's search", () => {
|
||||
it("is named in full, an empty one too: this browser remembers one a link must not inherit", async () => {
|
||||
setAccountRaw(LS.plexQ, "poirot");
|
||||
mountAt("/?page=plex&pq=", "plex");
|
||||
await waitFor(() => expect(seen.current.plex).toBe(""));
|
||||
expect(search().get("pq")).toBe("");
|
||||
});
|
||||
|
||||
it("takes the field a link names, and remembers the link's search", async () => {
|
||||
mountAt("/?page=plex&pq=hastings&pqf=people", "plex");
|
||||
await waitFor(() => expect(seen.current.plexField).toBe("people"));
|
||||
expect(seen.current.plex).toBe("hastings");
|
||||
await waitFor(() => expect(getAccountRaw(LS.plexQ)).toBe("hastings"));
|
||||
expect(search().get("pqf")).toBe("people");
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,14 @@ import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistr
|
||||
import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api";
|
||||
import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState";
|
||||
import { channelsParamUpdates } from "./lib/channelsUrlState";
|
||||
import { plexParamUpdates } from "./lib/plexUrlState";
|
||||
import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
|
||||
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
|
||||
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
|
||||
import { FILES_KEYS } from "./lib/filesUrlState";
|
||||
import { playlistViewParamUpdates } from "./lib/playlistsUrlState";
|
||||
import { SETTINGS_TAB_KEY } from "./lib/settingsTab";
|
||||
import { CONFIG_TAB_KEY } from "./lib/configTab";
|
||||
import { USERS_TAB_URL_KEY } from "./lib/adminUsersTab";
|
||||
import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl";
|
||||
import { MODE_URL } from "./shell/welcomeMode";
|
||||
import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState";
|
||||
@@ -40,15 +44,23 @@ const FEED: FeedFilters = {
|
||||
const WRITES: Partial<Record<Page, () => string[]>> = {
|
||||
feed: () => Object.keys(filterParamUpdates(FEED)),
|
||||
channels: () =>
|
||||
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {} })),
|
||||
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })),
|
||||
plex: () => [
|
||||
...Object.keys(
|
||||
plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }),
|
||||
),
|
||||
...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })),
|
||||
PLEX_SUB_URL.key,
|
||||
],
|
||||
files: () => [...FILES_KEYS],
|
||||
playlists: () => ["pl"],
|
||||
settings: () => [SETTINGS_TAB_KEY],
|
||||
config: () => [CONFIG_TAB_KEY],
|
||||
users: () => [USERS_TAB_URL_KEY],
|
||||
playlists: () => [
|
||||
"pl",
|
||||
"plq",
|
||||
...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })),
|
||||
],
|
||||
activity: () => [
|
||||
...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)),
|
||||
...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user