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:
@@ -10,15 +10,41 @@
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||
export function useHistorySubview<T>(root: T): {
|
||||
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||||
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||
export interface SubviewUrl<T> {
|
||||
/** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */
|
||||
key: string;
|
||||
/** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */
|
||||
encode: (view: T) => string | null;
|
||||
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */
|
||||
decode: (raw: string) => T | null;
|
||||
}
|
||||
|
||||
export function useHistorySubview<T>(
|
||||
root: T,
|
||||
url?: SubviewUrl<T>,
|
||||
): {
|
||||
view: T;
|
||||
open: (next: T) => void;
|
||||
back: () => void;
|
||||
} {
|
||||
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
|
||||
const [view, setView] = useState<T>(() => {
|
||||
// Reload of an in-app entry: history.state._sub. Fresh bookmark: decode the ?<key> param.
|
||||
const fromState = window.history.state?._sub as T | undefined;
|
||||
if (fromState != null) return fromState;
|
||||
if (url) {
|
||||
const raw = new URLSearchParams(window.location.search).get(url.key);
|
||||
const decoded = raw != null ? url.decode(raw) : null;
|
||||
if (decoded != null) return decoded;
|
||||
}
|
||||
return root;
|
||||
});
|
||||
useEffect(() => {
|
||||
// Back/Forward restores the sub-view from history.state (the browser restores each entry's URL too).
|
||||
const onPop = () => setView((window.history.state?._sub as T) ?? root);
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
@@ -28,7 +54,8 @@ export function useHistorySubview<T>(root: T): {
|
||||
view,
|
||||
open: (next: T) => {
|
||||
setView(next);
|
||||
window.history.pushState({ ...window.history.state, _sub: next }, "");
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
window.history.pushState({ ...window.history.state, _sub: next }, "", href);
|
||||
},
|
||||
back: () => window.history.back(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user