feat(nav): full bookmarkable URL model — retire minimalist-URL + Share-view (E4 S2)

The query string is now the source of truth for the current view, so every URL is
bookmarkable/shareable. Reverses the early minimalist-URL rule (user's call).

- urlState: urlWithParams (read-modify-write delta on the live query, each provider
  owns its keys) + clearParams (strip only transient auth params) replace the whole-
  query stripUrlParams; shareUrl deleted; filterParamUpdates helper.
- NavigationProvider: page + ?chan mirrored to the address bar (pushState URL arg;
  history.state mechanics untouched); loadInitialPage resolves deep links
  (?v->plex, ?pl->playlists, filter/?chan->feed, bare->stored landing).
- FeedFiltersProvider: feed filters mirrored to the URL, gated on page===feed.
- useHistorySubview: optional SubviewUrl mapping; PlexBrowse mirrors the sub-view to
  ?v=<kind>:<args> (show/season/info/person/playlist; grid+player stay ephemeral).
- PlaylistsProvider: selected playlist mirrored to ?pl on the Playlists page.
- App: the 5 stripUrlParams calls become targeted clearParams (link/oauth/admin/
  access/verify/reset); the share-link hydrate effect removed.
- Retire the "Share view" feature: Sidebar + SavedViewsWidget buttons + i18n keys.

Browser tab title stays the short module/channel name (document.title untouched) —
the query never leaks into it, per the user's request.

Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest (urlState.dom.test
+9). No browser-E2E yet (most visual sprint — flagged for the epic-end review).
This commit is contained in:
2026-08-19 04:47:36 +02:00
parent 48aa1a25f5
commit 8bfc01684f
14 changed files with 362 additions and 115 deletions
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
//
// The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so
// each provider owns its own key without clobbering the others), clearParams (strip only transient
// auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete
// delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too.
import { describe, it, expect, beforeEach } from "vitest";
import {
urlWithParams,
clearParams,
filterParamUpdates,
filtersToParams,
paramsToFilters,
} from "./urlState";
import type { FeedFilters } from "./api";
function setLocation(search: string) {
window.history.replaceState(null, "", `/app${search}`);
}
function queryOf(url: string): URLSearchParams {
return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
}
const DEFAULTS: FeedFilters = {
tags: [],
tagMode: "or",
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
describe("urlWithParams", () => {
beforeEach(() => setLocation(""));
it("sets a key while preserving the pathname and existing params", () => {
setLocation("?page=plex");
const url = urlWithParams({ v: "show:1" });
expect(url.startsWith("/app?")).toBe(true);
const q = queryOf(url);
expect(q.get("v")).toBe("show:1");
expect(q.get("page")).toBe("plex"); // untouched
});
it("deletes a key on a null value and leaves the others", () => {
setLocation("?page=plex&v=show:1&q=react");
const q = queryOf(urlWithParams({ v: null }));
expect(q.has("v")).toBe(false);
expect(q.get("page")).toBe("plex");
expect(q.get("q")).toBe("react");
});
it("applies a mix of sets and deletes in one call", () => {
setLocation("?page=plex&v=show:1&chan=UC1");
const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null }));
expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys
});
it("returns a bare path when the query empties out", () => {
setLocation("?page=channels");
expect(urlWithParams({ page: null })).toBe("/app");
});
});
describe("clearParams", () => {
beforeEach(() => setLocation(""));
it("removes only the named transient keys, keeping nav/filter params", () => {
setLocation("?link=ok&page=plex&v=show:1&q=react");
clearParams("link", "oauth", "admin", "access", "verify", "reset");
const q = new URLSearchParams(window.location.search);
expect(q.has("link")).toBe(false);
expect(q.get("page")).toBe("plex");
expect(q.get("v")).toBe("show:1");
expect(q.get("q")).toBe("react");
});
it("is a no-op (no history write) when none of the keys are present", () => {
setLocation("?page=plex");
const before = window.location.search;
clearParams("link", "oauth");
expect(window.location.search).toBe(before);
});
});
describe("filterParamUpdates", () => {
it("maps non-default filters to values and defaults to null (delete)", () => {
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
const upd = filterParamUpdates(f);
expect(upd.q).toBe("react");
expect(upd.tags).toBe("3,7");
expect(upd.show).toBe("watched");
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared
expect(upd.scope).toBeNull();
});
it("clears ALL filter keys when passed null (leaving the feed)", () => {
const upd = filterParamUpdates(null);
for (const v of Object.values(upd)) expect(v).toBeNull();
// includes every recognized filter key
expect(Object.keys(upd)).toEqual(
expect.arrayContaining(["q", "tags", "show", "sort", "scope"]),
);
});
it("round-trips through the URL (filters → params → filters)", () => {
const f: FeedFilters = {
...DEFAULTS,
q: "hi",
tags: [1, 2],
scope: "all",
includeShorts: true,
};
const back = paramsToFilters(filtersToParams(f), DEFAULTS);
expect(back.q).toBe("hi");
expect(back.tags).toEqual([1, 2]);
expect(back.scope).toBe("all");
expect(back.includeShorts).toBe(true);
});
});