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
@@ -10,8 +10,14 @@ import {
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
import {
filterParamUpdates,
hasFilterParams,
paramsToFilters,
urlWithParams,
} from "../lib/urlState";
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
@@ -90,8 +96,8 @@ export function useFeedFiltersActions(): FeedFiltersActions {
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
// — such filters win over the account's stored view and are persisted to the account instead.
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search)),
);
@@ -108,6 +114,20 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
if (key) writeJSON(key, next);
}, []);
// Mirror the feed filters into the address bar so every feed view is bookmarkable (replaceState — a
// filter tweak is not a new history entry). Gated on page===feed: on other pages the filter keys are
// cleared from the URL (localStorage still holds them; they re-mirror on return to the feed). The
// channel detail page keeps its own isolated filters, so those never reach here. urlWithParams
// preserves the nav params (?page/?chan/?v/?pl) the other providers own.
const { page } = useNavigation();
useEffect(() => {
window.history.replaceState(
window.history.state,
"",
urlWithParams(filterParamUpdates(page === "feed" ? filters : null)),
);
}, [filters, page]);
// Load THIS account's own filters when the account first resolves or changes (login / switch /
// add). Filters are per-account localStorage state, so another account's last view (incl. its
// default saved view) must never carry over. A share link's filters (captured at mount) win and
+51 -17
View File
@@ -9,7 +9,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { isPage, readPage, type Page } from "../lib/urlState";
import { hasFilterParams, isPage, readPage, urlWithParams, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
@@ -52,6 +52,12 @@ export interface NavigationActions {
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → the account's stored landing page.
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
@@ -88,10 +94,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped).
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
const st = window.history.state;
const id =
(st?._chan as string | undefined) ??
new URLSearchParams(window.location.search).get("chan") ??
undefined;
return id ? { id, name: (st?._chanName as string) ?? undefined } : null;
});
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
@@ -116,8 +128,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
// rather than being orphaned from history so Back can't close it.
window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
// address bar too (urlWithParams keeps any ?chan/filter params already there).
window.history.replaceState(
{ ...(window.history.state || {}), sfPage: stored },
"",
urlWithParams({ page: stored === "feed" ? null : stored }),
);
});
const setPage = useCallback(
@@ -130,9 +147,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
// _yt/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
// FeedFiltersProvider) are left intact.
window.history.pushState(
{ sfPage: next },
"",
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
);
},
[markDirty],
);
@@ -143,13 +166,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
const url = urlWithParams({ chan: id });
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
// drop the ?chan param from the address bar.
setChannelView(null);
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
}
}, []);
const enterYtSearch = useCallback((q: string) => {
@@ -189,19 +219,23 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]);
// In-app Back/Forward: stamp the initial entry with the current page, then sync state from
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
// later query-string strip in App.)
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5.
const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
const url = urlWithParams({ page: page === "feed" ? null : page });
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "");
window.history.replaceState({ ...st, sfPage: page }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, "");
window.history.replaceState({ ...rest, sfPage: page }, "", url);
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
+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 }),
+46 -2
View File
@@ -54,7 +54,7 @@ import {
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { useHistorySubview, type SubviewUrl } from "../lib/history";
import {
DetailCustomizeMenu,
Filterable,
@@ -83,6 +83,50 @@ type Sub =
| { kind: "person"; name: string }
| { kind: "playlist"; id: number };
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person
// name is the whole tail after the first ":", so a name containing ":" round-trips.
const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v",
encode: (v) => {
switch (v.kind) {
case "show":
return `show:${v.id}`;
case "season":
return `season:${v.showId}:${v.seasonId}`;
case "info":
return `info:${v.id}`;
case "person":
return `person:${v.name}`;
case "playlist":
return `playlist:${v.id}`;
default:
return null; // grid + the ephemeral player are not URL-mirrored
}
},
decode: (raw) => {
const i = raw.indexOf(":");
const kind = i === -1 ? raw : raw.slice(0, i);
const arg = i === -1 ? "" : raw.slice(i + 1);
switch (kind) {
case "show":
return arg ? { kind: "show", id: arg } : null;
case "info":
return arg ? { kind: "info", id: arg } : null;
case "person":
return arg ? { kind: "person", name: arg } : null;
case "playlist":
return arg && Number.isFinite(Number(arg)) ? { kind: "playlist", id: Number(arg) } : null;
case "season": {
const [showId, seasonId] = arg.split(":");
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
}
default:
return null;
}
},
};
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
// search term comes from the shared top Header search box (q). A show drill-down rides history
@@ -135,7 +179,7 @@ export default function PlexBrowse() {
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
const fieldState = healPlexField(field, scope);
const sub = useHistorySubview<Sub>({ kind: "grid" });
const sub = useHistorySubview<Sub>({ kind: "grid" }, PLEX_SUB_URL);
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
// clear the signal so it doesn't re-open.
+2 -22
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
DndContext,
@@ -19,10 +19,8 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { filtersToParams } from "../lib/urlState";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
@@ -131,14 +129,6 @@ export default function SavedViewsWidget({
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
}
async function share(v: SavedView) {
if (await copyText(shareUrl(asFilters(v.filters)))) {
notify({ level: "success", message: t("views.shareCopied") });
} else {
notify({ level: "warning", message: t("sidebar.shareFailed") });
}
}
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
@@ -177,7 +167,6 @@ export default function SavedViewsWidget({
onRename={(name) => rename(v.id, name)}
onCancelRename={() => setRenameId(null)}
onToggleDefault={() => toggleDefault(v)}
onShare={() => share(v)}
onDelete={() => remove(v)}
/>
))}
@@ -239,7 +228,6 @@ function ViewRow({
onRename,
onCancelRename,
onToggleDefault,
onShare,
onDelete,
}: {
view: SavedView;
@@ -250,7 +238,6 @@ function ViewRow({
onRename: (name: string) => void;
onCancelRename: () => void;
onToggleDefault: () => void;
onShare: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
@@ -339,13 +326,6 @@ function ViewRow({
>
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
</button>
<button
onClick={onShare}
title={t("views.share")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
>
<Share2 className="w-3.5 h-3.5" />
</button>
<button
onClick={onStartRename}
title={t("views.rename")}
+1 -20
View File
@@ -2,12 +2,9 @@ import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
@@ -126,14 +123,6 @@ export default function Sidebar({
const activeCount = useActiveFilters(filters, setFilters).length;
const active = activeCount > 0;
async function shareView() {
if (await copyText(shareUrl(filters))) {
notify({ level: "success", message: t("sidebar.shareCopied") });
} else {
notify({ level: "warning", message: t("sidebar.shareFailed") });
}
}
function clearAll() {
setCustomDates(false);
setFilters(clearedFilters(filters));
@@ -321,14 +310,6 @@ export default function Sidebar({
>
{t("sidebar.clearAll")}
</button>
<button
onClick={shareView}
title={t("sidebar.shareView")}
aria-label={t("sidebar.shareView")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Share2 className="w-4 h-4" />
</button>
</>
);