The Channels-manager content state now round-trips through the address bar,
so a manager view is bookmarkable/shareable — the S4 pattern applied to the
last content page the audit flagged.
- New lib/channelsUrlState.ts (channelsParamUpdates / channelsParamsToState /
hasChannelsParams) under NAMESPACED keys ctab/csort/cf — channels-unique
like ?v=/?pl/pscope, so no other provider reads or clears them and the
feed's URL-stripping can't delete them. The mixed scalar/array FilterMap
(sync/priority scalars + tags multi) rides in `cf` as a JSON blob so both
shapes round-trip exactly. Only non-default values emit.
- ChannelsProvider mirrors {view, sort, filters} via useUrlMirror, page-gated
on channels (off-page the keys clear; localStorage still holds the state and
it re-mirrors on return). A shared link wins over stored state: readChannels
UrlSeed captured once, applied before paint through the slice setters (so the
account-pin re-hydrate sees them dirty and won't reload storage over the
shared view), then persisted to the account once pinned so it survives a
later plain reload — mirrors PlexProvider's seed/rehydrate. The name search
stays ephemeral (out of the shared contract).
- NavigationProvider's page-switch nulls ctab/csort/cf alongside the plex keys
so switching modules resets to the manager root.
Tests: channelsUrlState.dom.test.ts (9 round-trip) + ChannelsProvider.dom
seed/mirror (2); the existing tab-history test's beforeEach now resets the
query string too (the mirror writes it). FE vitest 503→514. FE-only.
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { urlWithParams, type Page } from "../lib/urlState";
|
|
import { pageTitleKey } from "../lib/pageMeta";
|
|
import { setAccountRaw } from "../lib/storage";
|
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|
import { setNavigator } from "../lib/nav";
|
|
import { nextNavId, notifyNav } from "../lib/history";
|
|
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
|
|
|
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
|
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
|
|
// Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside
|
|
// ConfirmProvider, around <App/>.
|
|
//
|
|
// TWO contexts on purpose: a STABLE actions context (setPage/openChannel/… never change reference)
|
|
// and a CHANGING state context (page/channelView/ytSearch). Context consumers re-render whenever the
|
|
// value changes, so deep memo'd leaves (VideoCard, PlayerModal) subscribe to actions ONLY and never
|
|
// re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering
|
|
// every existing card.
|
|
|
|
export interface NavigationState {
|
|
page: Page;
|
|
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
|
|
channelView: ChannelView | null;
|
|
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
|
|
ytSearch: string | null;
|
|
}
|
|
|
|
export interface NavigationActions {
|
|
setPage: (next: Page) => void;
|
|
openChannel: (id: string, name?: string) => void;
|
|
closeChannel: () => void;
|
|
enterYtSearch: (q: string) => void;
|
|
exitYtSearch: () => void;
|
|
}
|
|
|
|
const StateContext = createContext<NavigationState>({
|
|
page: "feed",
|
|
channelView: null,
|
|
ytSearch: null,
|
|
});
|
|
const ActionsContext = createContext<NavigationActions>({
|
|
setPage: () => {},
|
|
openChannel: () => {},
|
|
closeChannel: () => {},
|
|
enterYtSearch: () => {},
|
|
exitYtSearch: () => {},
|
|
});
|
|
|
|
export function useNavigation(): NavigationState {
|
|
return useContext(StateContext);
|
|
}
|
|
export function useNavigationActions(): NavigationActions {
|
|
return useContext(ActionsContext);
|
|
}
|
|
|
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
|
const { t, i18n } = useTranslation();
|
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
|
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
|
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
|
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
|
// Back steps player → search → feed instead of the search vanishing without a history entry.
|
|
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
|
const st = window.history.state;
|
|
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) 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>(() =>
|
|
channelViewFrom(window.history.state, window.location.search),
|
|
);
|
|
|
|
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
|
|
// would recreate them on every navigation and re-render every actions consumer (e.g. VideoCard).
|
|
// Written during render (the sanctioned "latest value" pattern): the ref is only READ in event
|
|
// handlers after commit, and this provider never suspends or renders in a transition, so no
|
|
// discarded render can leave it stale.
|
|
const pageRef = useRef(page);
|
|
pageRef.current = page;
|
|
const channelViewRef = useRef(channelView);
|
|
channelViewRef.current = channelView;
|
|
|
|
// The landing page is per-account (PAGE_KEY), but this provider mounts above <App>, which pins the
|
|
// tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
|
|
// falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
|
|
// (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
|
|
// skips this if the user already navigated in the pre-pin window.
|
|
const markDirty = useAccountRehydrate(() => {
|
|
const stored = loadInitialPage();
|
|
if (stored === pageRef.current) return;
|
|
setPageState(stored);
|
|
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. 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(
|
|
(next: Page) => {
|
|
// A channel page overlays the content column, so a nav click must close it even when the
|
|
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
|
if (next === pageRef.current && !channelViewRef.current) return;
|
|
markDirty();
|
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
|
setPageState(next);
|
|
setAccountRaw(PAGE_KEY, next);
|
|
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
|
// 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 and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
|
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
|
|
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
|
|
window.history.pushState(
|
|
{ sfPage: next, sfScroll: nextNavId() },
|
|
"",
|
|
urlWithParams({
|
|
page: next === "feed" ? null : next,
|
|
chan: null,
|
|
v: null,
|
|
pl: null,
|
|
pscope: null,
|
|
psort: null,
|
|
pshow: null,
|
|
pf: null,
|
|
ctab: null,
|
|
csort: null,
|
|
cf: null,
|
|
}),
|
|
);
|
|
notifyNav();
|
|
},
|
|
[markDirty],
|
|
);
|
|
|
|
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
|
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
|
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
|
const openChannel = useCallback((id: string, name?: string) => {
|
|
setChannelView({ id, name });
|
|
const st = window.history.state || {};
|
|
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 {
|
|
// 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) => {
|
|
// Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
|
|
setYtSearch(q);
|
|
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
|
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
|
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
|
if (st._yt)
|
|
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
|
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
|
|
}, []);
|
|
|
|
const exitYtSearch = useCallback(() => {
|
|
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
|
|
if (window.history.state?._yt) window.history.back();
|
|
else setYtSearch(null);
|
|
}, []);
|
|
|
|
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
|
|
useEffect(() => setNavigator(setPage), [setPage]);
|
|
|
|
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
|
|
useEffect(() => {
|
|
if (page !== "feed") setYtSearch(null);
|
|
}, [page]);
|
|
|
|
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
|
|
// shows the channel name.
|
|
useEffect(() => {
|
|
const brand = "Siftlode";
|
|
const label = channelView
|
|
? channelView.name || t("header.channelManager")
|
|
: page === "feed"
|
|
? null
|
|
: t(pageTitleKey(page));
|
|
document.title = label ? `${label} · ${brand}` : brand;
|
|
}, [page, channelView, i18n.language, t]);
|
|
|
|
// 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 }, "", url);
|
|
} else {
|
|
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
|
window.history.replaceState({ ...rest, sfPage: page }, "", url);
|
|
}
|
|
function onPop(e: PopStateEvent) {
|
|
const p = (e.state?.sfPage as Page) ?? "feed";
|
|
setPageState(p);
|
|
setAccountRaw(PAGE_KEY, p);
|
|
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
|
// restores or clears them to match the entry we landed on.
|
|
setYtSearch((e.state?._yt as string) ?? null);
|
|
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
|
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
|
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
|
|
// shares this resolution with the seed initializer above.
|
|
setChannelView(channelViewFrom(e.state, window.location.search));
|
|
}
|
|
window.addEventListener("popstate", onPop);
|
|
return () => window.removeEventListener("popstate", onPop);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const state = useMemo<NavigationState>(
|
|
() => ({ page, channelView, ytSearch }),
|
|
[page, channelView, ytSearch],
|
|
);
|
|
const actions = useMemo<NavigationActions>(
|
|
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
|
|
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
|
|
);
|
|
|
|
return (
|
|
<ActionsContext.Provider value={actions}>
|
|
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
|
</ActionsContext.Provider>
|
|
);
|
|
}
|