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
+47 -16
View File
@@ -1,7 +1,9 @@
// Serialize feed filters to/from a compact, human-readable URL query string. Filters are
// NOT kept in the address bar during normal use (localStorage is the single source of
// truth); this serializer powers the explicit "Share view" link and the one-time hydration
// when someone opens such a link. Only non-default values are emitted to keep URLs clean.
// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the
// address-bar URL for the whole app's navigation. The query string IS the source of truth for the
// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import type { FeedFilters } from "./api";
const KEYS = [
@@ -46,6 +48,17 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
return p;
}
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
* Feed the result into urlWithParams(). */
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
const p = f ? filtersToParams(f) : new URLSearchParams();
const out: Record<string, string | null> = {};
for (const k of KEYS) out[k] = p.get(k);
return out;
}
/** Merge URL params onto a base filter set (typically the defaults). */
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
const f: FeedFilters = { ...base };
@@ -117,20 +130,38 @@ export function readPage(): Page {
return isPage(p) ? p : "feed";
}
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
* opt-in "Share view" action — filters are not otherwise written to the address bar. */
export function shareUrl(f: FeedFilters, page: Page = "feed"): string {
const params = filtersToParams(f);
if (page !== "feed") params.set("page", page);
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
export function urlWithParams(updates: Record<string, string | null>): string {
const params = new URLSearchParams(window.location.search);
for (const [k, v] of Object.entries(updates)) {
if (v == null) params.delete(k);
else params.set(k, v);
}
const qs = params.toString();
return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`;
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
}
/** Strip the query string from the address bar (after hydrating from a share/auth link),
* leaving a clean URL. Preserves the current history.state (e.g. the in-app `sfPage` stamp)
* so it's safe to call at any time, not just before that stamp is written. */
export function stripUrlParams(): void {
if (window.location.search) {
window.history.replaceState(window.history.state, "", window.location.pathname);
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
export function clearParams(...keys: string[]): void {
const params = new URLSearchParams(window.location.search);
let changed = false;
for (const k of keys) {
if (params.has(k)) {
params.delete(k);
changed = true;
}
}
if (!changed) return;
const qs = params.toString();
window.history.replaceState(
window.history.state,
"",
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
);
}