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:
+12
-21
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
import {
|
||||
loadLayout,
|
||||
@@ -103,17 +103,8 @@ export default function App() {
|
||||
localKey: LS.playlistsCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||
// link, its params have already hydrated the initial state — persist them and strip the
|
||||
// query so the URL stays clean from here on.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params) || params.has("page")) {
|
||||
// A share link's filters are held in state; they're persisted to the account's key by the
|
||||
// post-login effect (which knows the account id). Here we only clean the address bar.
|
||||
stripUrlParams();
|
||||
}
|
||||
}, []);
|
||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||
@@ -145,11 +136,11 @@ export default function App() {
|
||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
||||
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
||||
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
useEffect(() => {
|
||||
if (meQuery.data) stripUrlParams();
|
||||
if (meQuery.data) clearParams("access", "verify", "reset");
|
||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
@@ -181,7 +172,7 @@ export default function App() {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
stripUrlParams();
|
||||
clearParams("link");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||
@@ -190,12 +181,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
stripUrlParams();
|
||||
clearParams("oauth");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
||||
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
useEffect(() => {
|
||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||
@@ -203,7 +194,7 @@ export default function App() {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
stripUrlParams();
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,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")}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
"reshuffle": "Reshuffle",
|
||||
"noMatchingTags": "No matching tags here",
|
||||
"manageTags": "Manage",
|
||||
"shareView": "Copy a link to this view",
|
||||
"shareCopied": "View link copied to clipboard",
|
||||
"shareFailed": "Couldn't copy the link",
|
||||
"widget": {
|
||||
"savedviews": "Saved views",
|
||||
"show": "Show",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"apply": "Apply this view",
|
||||
"makeDefault": "Set as default (loads on start)",
|
||||
"isDefault": "Default view — loads on start",
|
||||
"share": "Copy a link to this view",
|
||||
"shareCopied": "View link copied",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
"reshuffle": "Újrakeverés",
|
||||
"noMatchingTags": "Nincs ide illő címke",
|
||||
"manageTags": "Kezelés",
|
||||
"shareView": "Hivatkozás másolása erre a nézetre",
|
||||
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
|
||||
"shareFailed": "Nem sikerült a hivatkozás másolása",
|
||||
"widget": {
|
||||
"savedviews": "Mentett nézetek",
|
||||
"show": "Megjelenítés",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"apply": "Nézet alkalmazása",
|
||||
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
||||
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
||||
"share": "Hivatkozás másolása a nézethez",
|
||||
"shareCopied": "Nézet hivatkozása a vágólapra másolva",
|
||||
"rename": "Átnevezés",
|
||||
"delete": "Törlés",
|
||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
||||
|
||||
@@ -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