refactor(nav): lift navigation + history into NavigationProvider
Move page/channelView/ytSearch and the history/popstate/title effects out of the App god component into a new NavigationProvider (split state/actions contexts so the memo'd VideoCard doesn't re-render on a page/channel switch). App consumes it via useNavigation/useNavigationActions but still passes the props down for now; the consumer migration that removes the prop-drilling follows.
This commit is contained in:
+7
-119
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
saveLocalTheme,
|
||||
type ThemePrefs,
|
||||
} from "./lib/theme";
|
||||
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
||||
import { pageTitleKey } from "./lib/pageMeta";
|
||||
import { hasFilterParams, paramsToFilters, stripUrlParams } from "./lib/urlState";
|
||||
import {
|
||||
loadLayout,
|
||||
normalizeLayout,
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
writeJSON,
|
||||
} from "./lib/storage";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
import Welcome from "./components/Welcome";
|
||||
@@ -60,7 +60,6 @@ import Toaster from "./components/Toaster";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import { setNavigator } from "./lib/nav";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
||||
@@ -101,7 +100,6 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||
};
|
||||
|
||||
const FILTERS_KEY = LS.filters;
|
||||
const PAGE_KEY = LS.page;
|
||||
const PERF_KEY = LS.perfMode;
|
||||
|
||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
||||
@@ -116,13 +114,6 @@ type EditablePrefs = {
|
||||
|
||||
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
||||
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
||||
function loadInitialPage(): Page {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("page")) return readPage();
|
||||
const stored = getAccountRaw(PAGE_KEY);
|
||||
return isPage(stored) ? stored : "feed";
|
||||
}
|
||||
|
||||
// This account's own persisted filters (per-account keys — never another account's). A chosen
|
||||
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
|
||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||
@@ -156,6 +147,8 @@ function loadInitialFilters(): FeedFilters {
|
||||
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
||||
@@ -190,58 +183,10 @@ export default function App() {
|
||||
plex: loadLayout("plex"),
|
||||
playlists: loadLayout("playlists"),
|
||||
}));
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
|
||||
// SearchBar can drive them (the modules read/seed them via props).
|
||||
const [channelsSearch, setChannelsSearch] = useState("");
|
||||
const [playlistsSearch, setPlaylistsSearch] = useState("");
|
||||
// Live YouTube search term (null = normal feed). 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).
|
||||
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
|
||||
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
|
||||
// that order (a player opened over the results pops first, then the search, then the feed) —
|
||||
// instead of the search vanishing because it had no history entry of its own.
|
||||
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
||||
// Restore a live search across a reload ONLY when it was served by the zero-quota scrape
|
||||
// source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
|
||||
// dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
|
||||
const st = window.history.state;
|
||||
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
||||
});
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
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);
|
||||
}, []);
|
||||
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
|
||||
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
|
||||
// before the detail loads), so Back closes the channel page (after any player opened over it)
|
||||
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
|
||||
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
|
||||
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
|
||||
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
|
||||
const c = window.history.state?._chan as string | undefined;
|
||||
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
|
||||
});
|
||||
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 closeChannel = useCallback(() => {
|
||||
if (window.history.state?._chan) window.history.back();
|
||||
else setChannelView(null);
|
||||
}, []);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
||||
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
|
||||
@@ -298,9 +243,9 @@ export default function App() {
|
||||
setPage("channels");
|
||||
};
|
||||
useEffect(() => {
|
||||
// Clear the channel-manager focus when leaving the Channels page so it doesn't re-apply later.
|
||||
// (Leaving the feed clearing the live search is now the NavigationProvider's job.)
|
||||
if (page !== "channels") setFocusChannelName(null);
|
||||
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
|
||||
if (page !== "feed") setYtSearch(null);
|
||||
}, [page]);
|
||||
|
||||
function openReleaseNotes(highlight?: string) {
|
||||
@@ -315,34 +260,6 @@ export default function App() {
|
||||
if (key) writeJSON(key, next);
|
||||
}
|
||||
|
||||
function setPage(next: Page) {
|
||||
// While a channel page is open it 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 === page && !channelView) return;
|
||||
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 the browser/mouse Back button steps through pages instead of
|
||||
// leaving the app (e.g. back to the OAuth redirect). The URL stays clean — the page rides in
|
||||
// history.state, not the query string. A fresh { sfPage } (no carried-over _yt/_chan markers)
|
||||
// so each page starts at its root.
|
||||
window.history.pushState({ sfPage: next }, "");
|
||||
}
|
||||
// Expose the current setPage to decoupled callers (download toast "View", etc.).
|
||||
useEffect(() => setNavigator(setPage));
|
||||
|
||||
// Reflect the current module in the browser tab title so open tabs are distinguishable and the
|
||||
// history reads sensibly. 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]);
|
||||
|
||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||
saveLayoutLocal(panel, next);
|
||||
@@ -471,35 +388,6 @@ export default function App() {
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// In-app Back/Forward: stamp the initial entry with the current page, then sync `page`
|
||||
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
|
||||
// survives a later query-string strip.)
|
||||
useEffect(() => {
|
||||
// A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
|
||||
// scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
|
||||
// free (ytSearch above already initialised from it). An api-source search is still dropped (it
|
||||
// would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
|
||||
const st = window.history.state || {};
|
||||
if (st._yt && st._ytScrape) {
|
||||
window.history.replaceState({ ...st, sfPage: page }, "");
|
||||
} else {
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
||||
}
|
||||
function onPop(e: PopStateEvent) {
|
||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||
setPageState(p);
|
||||
setAccountRaw(PAGE_KEY, p);
|
||||
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
||||
// clears it to match the entry we landed on (and a player popped first leaves it intact).
|
||||
setYtSearch((e.state?._yt as string) ?? null);
|
||||
// The channel page rides in history.state._chan the same way.
|
||||
const chan = e.state?._chan as string | undefined;
|
||||
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isPage, readPage, type Page } from "../lib/urlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
|
||||
// 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.
|
||||
|
||||
const PAGE_KEY = LS.page;
|
||||
|
||||
export interface ChannelView {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function loadInitialPage(): Page {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("page")) return readPage();
|
||||
const stored = getAccountRaw(PAGE_KEY);
|
||||
return isPage(stored) ? stored : "feed";
|
||||
}
|
||||
|
||||
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), so Back closes it and a reload restores it (unlike ytSearch, which is dropped).
|
||||
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;
|
||||
});
|
||||
|
||||
// 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).
|
||||
const pageRef = useRef(page);
|
||||
pageRef.current = page;
|
||||
const channelViewRef = useRef(channelView);
|
||||
channelViewRef.current = channelView;
|
||||
|
||||
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;
|
||||
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. 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 }, "");
|
||||
}, []);
|
||||
|
||||
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 closeChannel = useCallback(() => {
|
||||
if (window.history.state?._chan) window.history.back();
|
||||
else setChannelView(null);
|
||||
}, []);
|
||||
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
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, then sync state from
|
||||
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
|
||||
// later query-string strip in App.)
|
||||
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 || {};
|
||||
if (st._yt && st._ytScrape) {
|
||||
window.history.replaceState({ ...st, sfPage: page }, "");
|
||||
} else {
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
||||
}
|
||||
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);
|
||||
const chan = e.state?._chan as string | undefined;
|
||||
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import { NavigationProvider } from "./components/NavigationProvider";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
|
||||
@@ -35,7 +36,9 @@ const root =
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
<NavigationProvider>
|
||||
<App />
|
||||
</NavigationProvider>
|
||||
</ConfirmProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
|
||||
Reference in New Issue
Block a user