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
+26 -1
View File
@@ -1,6 +1,16 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { urlWithParams } from "../lib/urlState";
import { useNavigation } from "./NavigationProvider";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
@@ -37,6 +47,9 @@ export function usePlaylistsActions(): PlaylistsActions {
}
function readSelectedId(): number | null {
// A ?pl bookmark wins over the stored selection so a pasted link opens that playlist.
const fromUrl = new URLSearchParams(window.location.search).get("pl");
if (fromUrl != null && Number.isFinite(Number(fromUrl))) return Number(fromUrl);
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
}
@@ -59,6 +72,18 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
[markDirty],
);
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
// (the localStorage copy still restores it on return). replaceState — selecting isn't a new history
// entry — and urlWithParams keeps ?page and the other nav params intact.
const { page } = useNavigation();
useEffect(() => {
window.history.replaceState(
window.history.state,
"",
urlWithParams({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null }),
);
}, [page, selectedId]);
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
() => ({ setSearch, setSelectedId }),