refactor(nav): read navigation from context, drop the prop-drilling
VideoCard/PlayerModal read openChannel from useNavigationActions (the memo'd VideoCard subscribes to the STABLE actions context, so a page/channel change no longer re-renders it); NavSidebar/Header read page/setPage; ChannelPage reads closeChannel; NotificationsPanel reads setPage. App sheds the matching props, and Feed/VirtualFeed no longer thread onOpenChannel at all.
This commit is contained in:
@@ -148,7 +148,7 @@ function loadInitialFilters(): FeedFilters {
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
const { setPage, openChannel, 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)
|
||||
@@ -636,8 +636,6 @@ export default function App() {
|
||||
<div className="h-screen flex text-fg">
|
||||
<NavSidebar
|
||||
me={meQuery.data!}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
onOpenAbout={() => setAboutOpen(true)}
|
||||
onChangeLanguage={changeLanguage}
|
||||
language={i18n.language as LangCode}
|
||||
@@ -699,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.
|
||||
@@ -730,8 +726,6 @@ export default function App() {
|
||||
setChannelsSearch={setChannelsSearch}
|
||||
playlistsSearch={playlistsSearch}
|
||||
setPlaylistsSearch={setPlaylistsSearch}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
onYtSearch={enterYtSearch}
|
||||
onGoToFullHistory={() => {
|
||||
setChannelFilter("needs_full");
|
||||
@@ -789,7 +783,7 @@ export default function App() {
|
||||
setSelectedId={setSelectedPlaylistId}
|
||||
/>
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} onFocusChannel={focusChannel} />
|
||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={meQuery.data!.id} />
|
||||
@@ -826,7 +820,6 @@ export default function App() {
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
onOpenChannel={openChannel}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
@@ -382,7 +380,6 @@ export default function ChannelPage({
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
onOpenChannel={onOpenChannel}
|
||||
channelScoped
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
|
||||
@@ -88,7 +88,6 @@ export default function Feed({
|
||||
ytSearch,
|
||||
onYtSearch,
|
||||
onExitYtSearch,
|
||||
onOpenChannel,
|
||||
channelScoped,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
@@ -107,9 +106,6 @@ export default function Feed({
|
||||
ytSearch: string | null;
|
||||
onYtSearch: (q: string) => 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
|
||||
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
||||
channelScoped?: boolean;
|
||||
@@ -308,13 +304,6 @@ export default function Feed({
|
||||
[qc]
|
||||
);
|
||||
|
||||
const handleOpenChannel = useCallback(
|
||||
(channelId: string, channelName: string) => {
|
||||
onOpenChannel?.(channelId, channelName);
|
||||
},
|
||||
[onOpenChannel]
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
||||
@@ -447,7 +436,6 @@ export default function Feed({
|
||||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={false}
|
||||
@@ -480,7 +468,6 @@ export default function Feed({
|
||||
queue={ytPlayerQueue}
|
||||
onClose={() => setActiveVideo(null)}
|
||||
onState={onState}
|
||||
onOpenChannel={onOpenChannel}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -698,7 +685,6 @@ export default function Feed({
|
||||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={!!hasNextPage}
|
||||
@@ -714,7 +700,6 @@ export default function Feed({
|
||||
queue={playerQueue}
|
||||
onClose={() => setActiveVideo(null)}
|
||||
onState={onState}
|
||||
onOpenChannel={onOpenChannel}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Youtube } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||
import ModuleName from "./ModuleName";
|
||||
import SearchBar from "./SearchBar";
|
||||
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 +
|
||||
// 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,
|
||||
playlistsSearch,
|
||||
setPlaylistsSearch,
|
||||
page,
|
||||
setPage,
|
||||
onYtSearch,
|
||||
onGoToFullHistory,
|
||||
}: {
|
||||
@@ -36,8 +34,6 @@ export default function Header({
|
||||
setChannelsSearch: (q: string) => void;
|
||||
playlistsSearch: string;
|
||||
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,
|
||||
// which can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
@@ -45,6 +41,8 @@ export default function Header({
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
// 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
|
||||
// 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 { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
@@ -40,8 +41,6 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
||||
// a thin icon-only rail. Account actions sit at the bottom in a popover.
|
||||
export default function NavSidebar({
|
||||
me,
|
||||
page,
|
||||
setPage,
|
||||
onOpenAbout,
|
||||
onChangeLanguage,
|
||||
language,
|
||||
@@ -49,8 +48,6 @@ export default function NavSidebar({
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
me: Me;
|
||||
page: Page;
|
||||
setPage: (p: Page) => void;
|
||||
onOpenAbout: () => void;
|
||||
onChangeLanguage: (code: LangCode) => void;
|
||||
language: LangCode;
|
||||
@@ -60,6 +57,8 @@ export default function NavSidebar({
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
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
|
||||
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
|
||||
// `collapsed` (persisted per-account) now means "unpinned".
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
|
||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
||||
@@ -32,15 +32,14 @@ const POLL_MS = 8000;
|
||||
export default function NotificationsPanel({
|
||||
filters,
|
||||
setFilters,
|
||||
setPage,
|
||||
onFocusChannel,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
setPage: (p: Page) => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||
["notifications"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
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 Avatar from "./Avatar";
|
||||
@@ -55,7 +56,6 @@ export default function PlayerModal({
|
||||
startIndex,
|
||||
onClose,
|
||||
onState,
|
||||
onOpenChannel,
|
||||
}: {
|
||||
video: Video;
|
||||
// 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;
|
||||
// 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.
|
||||
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { openChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const descFade = useScrollFade();
|
||||
// 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">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!onOpenChannel) return onClose();
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
||||
// 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
|
||||
// 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 = () => {
|
||||
window.removeEventListener("popstate", open);
|
||||
onOpenChannel(cid, cname);
|
||||
openChannel(cid, cname);
|
||||
};
|
||||
window.addEventListener("popstate", open);
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import {
|
||||
Bookmark,
|
||||
Check,
|
||||
@@ -368,7 +369,6 @@ function VideoCard({
|
||||
onState,
|
||||
onResetState,
|
||||
onToggleSave,
|
||||
onOpenChannel,
|
||||
onOpen,
|
||||
}: {
|
||||
video: Video;
|
||||
@@ -378,10 +378,10 @@ function VideoCard({
|
||||
onState: (id: string, status: string) => void;
|
||||
onResetState?: (id: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onOpenChannel?: (channelId: string, channelName: string) => void;
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { openChannel } = useNavigationActions();
|
||||
const spec = FEED_VIEW_SPEC[view];
|
||||
const watched = video.status === "watched";
|
||||
// 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"
|
||||
onClick={(e) => {
|
||||
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}
|
||||
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;
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onOpenChannel: (channelId: string, channelName: string) => void;
|
||||
onResetState: (id: string) => void;
|
||||
onOpen: (v: Video, startAt?: number | null) => void;
|
||||
hasNextPage: boolean;
|
||||
@@ -71,7 +70,6 @@ export default function VirtualFeed({
|
||||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onOpenChannel,
|
||||
onResetState,
|
||||
onOpen,
|
||||
hasNextPage,
|
||||
@@ -187,7 +185,6 @@ export default function VirtualFeed({
|
||||
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user