Merge Platform S3: lift navigation+history into NavigationProvider
This commit is contained in:
+8
-127
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -18,8 +18,7 @@ import {
|
|||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
import { hasFilterParams, paramsToFilters, stripUrlParams } from "./lib/urlState";
|
||||||
import { pageTitleKey } from "./lib/pageMeta";
|
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
@@ -44,6 +43,7 @@ import {
|
|||||||
writeJSON,
|
writeJSON,
|
||||||
} from "./lib/storage";
|
} from "./lib/storage";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
||||||
// Persistent shell — always mounted, so eagerly imported.
|
// Persistent shell — always mounted, so eagerly imported.
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
@@ -60,7 +60,6 @@ import Toaster from "./components/Toaster";
|
|||||||
import ErrorDialog from "./components/ErrorDialog";
|
import ErrorDialog from "./components/ErrorDialog";
|
||||||
import VersionBanner from "./components/VersionBanner";
|
import VersionBanner from "./components/VersionBanner";
|
||||||
import DemoBanner from "./components/DemoBanner";
|
import DemoBanner from "./components/DemoBanner";
|
||||||
import { setNavigator } from "./lib/nav";
|
|
||||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||||
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
||||||
@@ -101,7 +100,6 @@ const DEFAULT_FILTERS: FeedFilters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FILTERS_KEY = LS.filters;
|
const FILTERS_KEY = LS.filters;
|
||||||
const PAGE_KEY = LS.page;
|
|
||||||
const PERF_KEY = LS.perfMode;
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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() {
|
export default function App() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { page, channelView, ytSearch } = useNavigation();
|
||||||
|
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
// 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"),
|
plex: loadLayout("plex"),
|
||||||
playlists: loadLayout("playlists"),
|
playlists: loadLayout("playlists"),
|
||||||
}));
|
}));
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
|
||||||
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
|
// 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).
|
// SearchBar can drive them (the modules read/seed them via props).
|
||||||
const [channelsSearch, setChannelsSearch] = useState("");
|
const [channelsSearch, setChannelsSearch] = useState("");
|
||||||
const [playlistsSearch, setPlaylistsSearch] = 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);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
||||||
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
|
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
|
||||||
@@ -298,9 +243,9 @@ export default function App() {
|
|||||||
setPage("channels");
|
setPage("channels");
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
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);
|
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]);
|
}, [page]);
|
||||||
|
|
||||||
function openReleaseNotes(highlight?: string) {
|
function openReleaseNotes(highlight?: string) {
|
||||||
@@ -315,34 +260,6 @@ export default function App() {
|
|||||||
if (key) writeJSON(key, next);
|
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) {
|
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||||
saveLayoutLocal(panel, next);
|
saveLayoutLocal(panel, next);
|
||||||
@@ -471,35 +388,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // 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();
|
const queryClient = useQueryClient();
|
||||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||||
@@ -748,8 +636,6 @@ export default function App() {
|
|||||||
<div className="h-screen flex text-fg">
|
<div className="h-screen flex text-fg">
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
page={page}
|
|
||||||
setPage={setPage}
|
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
onOpenAbout={() => setAboutOpen(true)}
|
||||||
onChangeLanguage={changeLanguage}
|
onChangeLanguage={changeLanguage}
|
||||||
language={i18n.language as LangCode}
|
language={i18n.language as LangCode}
|
||||||
@@ -811,8 +697,6 @@ export default function App() {
|
|||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
view={view}
|
view={view}
|
||||||
setView={setView}
|
setView={setView}
|
||||||
onBack={closeChannel}
|
|
||||||
onOpenChannel={openChannel}
|
|
||||||
onShowInFeed={() => {
|
onShowInFeed={() => {
|
||||||
// Keep the rest of the feed's filters — the point is "this channel, the way I
|
// Keep the rest of the feed's filters — the point is "this channel, the way I
|
||||||
// normally read": unwatched, my tags, my date window.
|
// normally read": unwatched, my tags, my date window.
|
||||||
@@ -842,8 +726,6 @@ export default function App() {
|
|||||||
setChannelsSearch={setChannelsSearch}
|
setChannelsSearch={setChannelsSearch}
|
||||||
playlistsSearch={playlistsSearch}
|
playlistsSearch={playlistsSearch}
|
||||||
setPlaylistsSearch={setPlaylistsSearch}
|
setPlaylistsSearch={setPlaylistsSearch}
|
||||||
page={page}
|
|
||||||
setPage={setPage}
|
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
onGoToFullHistory={() => {
|
onGoToFullHistory={() => {
|
||||||
setChannelFilter("needs_full");
|
setChannelFilter("needs_full");
|
||||||
@@ -901,7 +783,7 @@ export default function App() {
|
|||||||
setSelectedId={setSelectedPlaylistId}
|
setSelectedId={setSelectedPlaylistId}
|
||||||
/>
|
/>
|
||||||
) : page === "notifications" ? (
|
) : page === "notifications" ? (
|
||||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
<NotificationsPanel filters={filters} setFilters={setFilters} onFocusChannel={focusChannel} />
|
||||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
<Messages meId={meQuery.data!.id} />
|
<Messages meId={meQuery.data!.id} />
|
||||||
@@ -938,7 +820,6 @@ export default function App() {
|
|||||||
ytSearch={ytSearch}
|
ytSearch={ytSearch}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
onExitYtSearch={exitYtSearch}
|
onExitYtSearch={exitYtSearch}
|
||||||
onOpenChannel={openChannel}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
@@ -57,8 +58,6 @@ export default function ChannelPage({
|
|||||||
me,
|
me,
|
||||||
view,
|
view,
|
||||||
setView,
|
setView,
|
||||||
onBack,
|
|
||||||
onOpenChannel,
|
|
||||||
onShowInFeed,
|
onShowInFeed,
|
||||||
}: {
|
}: {
|
||||||
channelId: string;
|
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.
|
// toolbar carries the same switcher and a change here is the same account-wide choice.
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
setView: (v: FeedView) => void;
|
setView: (v: FeedView) => void;
|
||||||
onBack: () => void;
|
|
||||||
// Narrow the real feed to this channel and go there. Offered only for a channel you're
|
// 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
|
// 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.
|
// follow would just land you on an empty page.
|
||||||
onShowInFeed: () => void;
|
onShowInFeed: () => void;
|
||||||
onOpenChannel: (id: string, name?: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { closeChannel } = useNavigationActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||||
@@ -245,7 +243,7 @@ export default function ChannelPage({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
data-testid="channel-back"
|
data-testid="channel-back"
|
||||||
onClick={onBack}
|
onClick={closeChannel}
|
||||||
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -382,7 +380,6 @@ export default function ChannelPage({
|
|||||||
ytSearch={null}
|
ytSearch={null}
|
||||||
onYtSearch={() => {}}
|
onYtSearch={() => {}}
|
||||||
onExitYtSearch={() => {}}
|
onExitYtSearch={() => {}}
|
||||||
onOpenChannel={onOpenChannel}
|
|
||||||
channelScoped
|
channelScoped
|
||||||
/>
|
/>
|
||||||
{!ch?.subscribed && nextToken && (
|
{!ch?.subscribed && nextToken && (
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export default function Feed({
|
|||||||
ytSearch,
|
ytSearch,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
onExitYtSearch,
|
onExitYtSearch,
|
||||||
onOpenChannel,
|
|
||||||
channelScoped,
|
channelScoped,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
@@ -107,9 +106,6 @@ export default function Feed({
|
|||||||
ytSearch: string | null;
|
ytSearch: string | null;
|
||||||
onYtSearch: (q: string) => void;
|
onYtSearch: (q: string) => void;
|
||||||
onExitYtSearch: () => void;
|
onExitYtSearch: () => void;
|
||||||
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
|
|
||||||
// the cards via VirtualFeed.
|
|
||||||
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
|
||||||
// True when this feed is scoped to a single channel (the channel page). Drops sort options
|
// True when this feed is scoped to a single channel (the channel page). Drops sort options
|
||||||
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
||||||
channelScoped?: boolean;
|
channelScoped?: boolean;
|
||||||
@@ -308,13 +304,6 @@ export default function Feed({
|
|||||||
[qc]
|
[qc]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenChannel = useCallback(
|
|
||||||
(channelId: string, channelName: string) => {
|
|
||||||
onOpenChannel?.(channelId, channelName);
|
|
||||||
},
|
|
||||||
[onOpenChannel]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onToggleSave = useCallback(
|
const onToggleSave = useCallback(
|
||||||
(id: string, saved: boolean) => {
|
(id: string, saved: boolean) => {
|
||||||
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
||||||
@@ -447,7 +436,6 @@ export default function Feed({
|
|||||||
view={view}
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={handleOpenChannel}
|
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
hasNextPage={false}
|
hasNextPage={false}
|
||||||
@@ -480,7 +468,6 @@ export default function Feed({
|
|||||||
queue={ytPlayerQueue}
|
queue={ytPlayerQueue}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -698,7 +685,6 @@ export default function Feed({
|
|||||||
view={view}
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={handleOpenChannel}
|
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
hasNextPage={!!hasNextPage}
|
hasNextPage={!!hasNextPage}
|
||||||
@@ -714,7 +700,6 @@ export default function Feed({
|
|||||||
queue={playerQueue}
|
queue={playerQueue}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { type ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Youtube } from "lucide-react";
|
import { Youtube } from "lucide-react";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
|
||||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||||
import ModuleName from "./ModuleName";
|
import ModuleName from "./ModuleName";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
|
|
||||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||||
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||||
@@ -21,8 +21,6 @@ export default function Header({
|
|||||||
setChannelsSearch,
|
setChannelsSearch,
|
||||||
playlistsSearch,
|
playlistsSearch,
|
||||||
setPlaylistsSearch,
|
setPlaylistsSearch,
|
||||||
page,
|
|
||||||
setPage,
|
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
@@ -36,8 +34,6 @@ export default function Header({
|
|||||||
setChannelsSearch: (q: string) => void;
|
setChannelsSearch: (q: string) => void;
|
||||||
playlistsSearch: string;
|
playlistsSearch: string;
|
||||||
setPlaylistsSearch: (q: string) => void;
|
setPlaylistsSearch: (q: string) => void;
|
||||||
page: Page;
|
|
||||||
setPage: (p: Page) => void;
|
|
||||||
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||||
// which can't spend the shared quota).
|
// which can't spend the shared quota).
|
||||||
onYtSearch: (q: string) => void;
|
onYtSearch: (q: string) => void;
|
||||||
@@ -45,6 +41,8 @@ export default function Header({
|
|||||||
onGoToFullHistory: () => void;
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { page } = useNavigation();
|
||||||
|
const { setPage } = useNavigationActions();
|
||||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||||
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
||||||
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -40,8 +41,6 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
|||||||
// a thin icon-only rail. Account actions sit at the bottom in a popover.
|
// a thin icon-only rail. Account actions sit at the bottom in a popover.
|
||||||
export default function NavSidebar({
|
export default function NavSidebar({
|
||||||
me,
|
me,
|
||||||
page,
|
|
||||||
setPage,
|
|
||||||
onOpenAbout,
|
onOpenAbout,
|
||||||
onChangeLanguage,
|
onChangeLanguage,
|
||||||
language,
|
language,
|
||||||
@@ -49,8 +48,6 @@ export default function NavSidebar({
|
|||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
page: Page;
|
|
||||||
setPage: (p: Page) => void;
|
|
||||||
onOpenAbout: () => void;
|
onOpenAbout: () => void;
|
||||||
onChangeLanguage: (code: LangCode) => void;
|
onChangeLanguage: (code: LangCode) => void;
|
||||||
language: LangCode;
|
language: LangCode;
|
||||||
@@ -60,6 +57,8 @@ export default function NavSidebar({
|
|||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { page } = useNavigation();
|
||||||
|
const { setPage } = useNavigationActions();
|
||||||
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
|
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
|
||||||
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
|
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
|
||||||
// `collapsed` (persisted per-account) now means "unpinned".
|
// `collapsed` (persisted per-account) now means "unpinned".
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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).
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
type VideoWatchedMeta,
|
type VideoWatchedMeta,
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||||
import type { Page } from "../lib/urlState";
|
|
||||||
import { LEVEL_STYLE } from "./Toaster";
|
import { LEVEL_STYLE } from "./Toaster";
|
||||||
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
|
|
||||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
||||||
@@ -32,15 +32,14 @@ const POLL_MS = 8000;
|
|||||||
export default function NotificationsPanel({
|
export default function NotificationsPanel({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
setPage,
|
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
setPage: (p: Page) => void;
|
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { setPage } = useNavigationActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||||
["notifications"],
|
["notifications"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
@@ -55,7 +56,6 @@ export default function PlayerModal({
|
|||||||
startIndex,
|
startIndex,
|
||||||
onClose,
|
onClose,
|
||||||
onState,
|
onState,
|
||||||
onOpenChannel,
|
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
||||||
@@ -70,9 +70,9 @@ export default function PlayerModal({
|
|||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
// Open the active video's channel page in-app (closes the player first). The small external
|
// Open the active video's channel page in-app (closes the player first). The small external
|
||||||
// icon next to the name keeps the open-on-YouTube behaviour.
|
// icon next to the name keeps the open-on-YouTube behaviour.
|
||||||
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { openChannel } = useNavigationActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const descFade = useScrollFade();
|
const descFade = useScrollFade();
|
||||||
// Browser/mouse Back closes the player instead of leaving the page.
|
// Browser/mouse Back closes the player instead of leaving the page.
|
||||||
@@ -840,16 +840,15 @@ export default function PlayerModal({
|
|||||||
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!onOpenChannel) return onClose();
|
|
||||||
const cid = active.channel_id;
|
const cid = active.channel_id;
|
||||||
const cname = active.channel_title ?? undefined;
|
const cname = active.channel_title ?? undefined;
|
||||||
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
||||||
// Open the channel only AFTER that pop's popstate — pushing the channel entry
|
// Open the channel only AFTER that pop's popstate — pushing the channel entry
|
||||||
// before it would let the back remove it again (the bug: clicking the name only
|
// before it would let the back remove it again (the bug: clicking the name only
|
||||||
// closed the player). One-shot listener fires after App's popstate handler.
|
// closed the player). One-shot listener fires after the popstate handler.
|
||||||
const open = () => {
|
const open = () => {
|
||||||
window.removeEventListener("popstate", open);
|
window.removeEventListener("popstate", open);
|
||||||
onOpenChannel(cid, cname);
|
openChannel(cid, cname);
|
||||||
};
|
};
|
||||||
window.addEventListener("popstate", open);
|
window.addEventListener("popstate", open);
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useEffect, useRef, useState } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Check,
|
Check,
|
||||||
@@ -368,7 +369,6 @@ function VideoCard({
|
|||||||
onState,
|
onState,
|
||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onOpenChannel,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
@@ -378,10 +378,10 @@ function VideoCard({
|
|||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onOpenChannel?: (channelId: string, channelName: string) => void;
|
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { openChannel } = useNavigationActions();
|
||||||
const spec = FEED_VIEW_SPEC[view];
|
const spec = FEED_VIEW_SPEC[view];
|
||||||
const watched = video.status === "watched";
|
const watched = video.status === "watched";
|
||||||
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
|
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
|
||||||
@@ -451,7 +451,7 @@ function VideoCard({
|
|||||||
data-testid="video-card-channel"
|
data-testid="video-card-channel"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||||
}}
|
}}
|
||||||
title={channelClamped ? video.channel_title ?? undefined : undefined}
|
title={channelClamped ? video.channel_title ?? undefined : undefined}
|
||||||
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
|
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export interface VirtualFeedProps {
|
|||||||
view: FeedView;
|
view: FeedView;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onOpenChannel: (channelId: string, channelName: string) => void;
|
|
||||||
onResetState: (id: string) => void;
|
onResetState: (id: string) => void;
|
||||||
onOpen: (v: Video, startAt?: number | null) => void;
|
onOpen: (v: Video, startAt?: number | null) => void;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
@@ -71,7 +70,6 @@ export default function VirtualFeed({
|
|||||||
view,
|
view,
|
||||||
onState,
|
onState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onOpenChannel,
|
|
||||||
onResetState,
|
onResetState,
|
||||||
onOpen,
|
onOpen,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
@@ -187,7 +185,6 @@ export default function VirtualFeed({
|
|||||||
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
|
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={onOpenChannel}
|
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
|
import { NavigationProvider } from "./components/NavigationProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -35,7 +36,9 @@ const root =
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<App />
|
<NavigationProvider>
|
||||||
|
<App />
|
||||||
|
</NavigationProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user