diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13a21521..5af82e6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 6ce59216..aca62f05 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -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(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 diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index c96ad3be..c8e4cd78 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -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=, 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(() => { - 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"; diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx index 25ba37d9..dcea3109 100644 --- a/frontend/src/components/PlaylistsProvider.tsx +++ b/frontend/src/components/PlaylistsProvider.tsx @@ -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(() => ({ search, selectedId }), [search, selectedId]); const actions = useMemo( () => ({ setSearch, setSelectedId }), diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 7c8440fc..4ec3fc5a 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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=: 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 = { + 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({ kind: "grid" }); + const sub = useHistorySubview({ 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. diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index 44d6b6dd..04caa02e 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -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({ > - - ); diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index ea21751c..8479f686 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -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", diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json index a2e41742..8a8b835b 100644 --- a/frontend/src/i18n/locales/en/views.json +++ b/frontend/src/i18n/locales/en/views.json @@ -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}}”?", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 6b8c01fa..5ed4f472 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json index d95f1c05..018a9138 100644 --- a/frontend/src/i18n/locales/hu/views.json +++ b/frontend/src/i18n/locales/hu/views.json @@ -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?", diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index ba1289fe..4cfe0460 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -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(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 { + /** 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( + root: T, + url?: SubviewUrl, +): { view: T; open: (next: T) => void; back: () => void; } { - const [view, setView] = useState(() => (window.history.state?._sub as T) ?? root); + const [view, setView] = useState(() => { + // Reload of an in-app entry: history.state._sub. Fresh bookmark: decode the ? 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(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(), }; diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts new file mode 100644 index 00000000..094ef348 --- /dev/null +++ b/frontend/src/lib/urlState.dom.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 4af6dc15..c432c2ed 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -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 { + const p = f ? filtersToParams(f) : new URLSearchParams(); + const out: Record = {}; + 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 { + 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}` : ""}`, + ); }