diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1051ef..4c649dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, enterYtSearch, exitYtSearch } = useNavigationActions(); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(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(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(() => { - // 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. @@ -748,8 +636,6 @@ export default function App() {
setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} @@ -811,8 +697,6 @@ export default function App() { me={meQuery.data!} view={view} setView={setView} - onBack={closeChannel} - onOpenChannel={openChannel} onShowInFeed={() => { // Keep the rest of the feed's filters — the point is "this channel, the way I // normally read": unwatched, my tags, my date window. @@ -842,8 +726,6 @@ export default function App() { setChannelsSearch={setChannelsSearch} playlistsSearch={playlistsSearch} setPlaylistsSearch={setPlaylistsSearch} - page={page} - setPage={setPage} onYtSearch={enterYtSearch} onGoToFullHistory={() => { setChannelFilter("needs_full"); @@ -901,7 +783,7 @@ export default function App() { setSelectedId={setSelectedPlaylistId} /> ) : page === "notifications" ? ( - + ) : page === "messages" && !meQuery.data!.is_demo ? (
@@ -938,7 +820,6 @@ export default function App() { ytSearch={ytSearch} onYtSearch={enterYtSearch} onExitYtSearch={exitYtSearch} - onOpenChannel={openChannel} /> )} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 67162cf..4c76763 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigationActions } from "./NavigationProvider"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import Avatar from "./Avatar"; import Feed from "./Feed"; @@ -57,8 +58,6 @@ export default function ChannelPage({ me, view, setView, - onBack, - onOpenChannel, onShowInFeed, }: { channelId: string; @@ -68,14 +67,13 @@ export default function ChannelPage({ // toolbar carries the same switcher and a change here is the same account-wide choice. view: FeedView; setView: (v: FeedView) => void; - onBack: () => void; // Narrow the real feed to this channel and go there. Offered only for a channel you're // subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't // follow would just land you on an empty page. onShowInFeed: () => void; - onOpenChannel: (id: string, name?: string) => void; }) { const { t, i18n } = useTranslation(); + const { closeChannel } = useNavigationActions(); const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); @@ -245,7 +243,7 @@ export default function ChannelPage({ )}