From a303f307cd31ebf53aff2015557ce699afced56b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 00:08:34 +0200 Subject: [PATCH] refactor(channels): scope module state in ChannelsProvider Lift the Channels manager's shared local state (name filter, status chip, active tab, column-reset token, focus-a-channel intent) out of App into a split-context ChannelsProvider, nested inside NavigationProvider so its focusChannel/goToFullHistory actions can drive navigation. The Channels page, the header search box + sync chip, the feed filter sidebar, and the notifications inbox all read it via useChannels()/useChannelsActions() instead of props drilled through App. Channels also internalises onViewChannel / onFilterByTag / onFocusChannel (nav + feed-filters straight from providers) and takes only { me, onOpenWizard } now. App loses five useStates, the focusChannel helper, the focus-clear effect, and a large block of per-page prop plumbing. (Platform S6b.2) --- frontend/src/App.tsx | 81 +--------- frontend/src/components/Channels.tsx | 70 ++++----- frontend/src/components/ChannelsProvider.tsx | 147 ++++++++++++++++++ frontend/src/components/Header.tsx | 15 +- .../src/components/NotificationsPanel.tsx | 10 +- frontend/src/components/Sidebar.tsx | 5 +- frontend/src/main.tsx | 5 +- 7 files changed, 199 insertions(+), 134 deletions(-) create mode 100644 frontend/src/components/ChannelsProvider.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c77465e4..1db90502 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,13 +32,11 @@ import { LS, readAccount, setAccountRaw, - useAccountPersistedState, writeAccount, } from "./lib/storage"; import type { PrefsController } from "./components/SettingsPanel"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; -import type { ChannelStatusFilter, ChannelsView } from "./components/Channels"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./components/Welcome"; import SetupWizard from "./components/SetupWizard"; @@ -95,7 +93,7 @@ type EditablePrefs = { export default function App() { const { t, i18n } = useTranslation(); const { page, channelView, ytSearch } = useNavigation(); - const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions(); + const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions(); const [theme, setThemeState] = useState(() => loadLocalTheme()); // The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load // effect + share-link capture all moved there). App reads them to hand to and to the @@ -129,49 +127,10 @@ export default function App() { plex: loadLayout("plex"), playlists: loadLayout("playlists"), })); - // 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 [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"); - const channelFilter: ChannelStatusFilter = ( - ["needs_full", "fully_synced", "hidden", "all"] as const - ).includes(channelFilterRaw as ChannelStatusFilter) - ? (channelFilterRaw as ChannelStatusFilter) - : "all"; const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); - // The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and - // persisted so navigation intents that target the subscriptions table — the header's - // "without full history" link, focus-channel — can force it back to "subscribed" instead - // of dumping the user on whatever tab they last left open. - const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed"); - const channelsView: ChannelsView = - channelsViewRaw === "discovery" - ? "discovery" - : channelsViewRaw === "blocked" - ? "blocked" - : "subscribed"; - // Bumped to tell the channel manager to drop a stale column filter when we send the user - // there to see a specific set (the header's "without full history" link). - const [channelsFilterReset, setChannelsFilterReset] = useState(0); - // "Focus this channel in the manager": jump to the Channels page and seed its name filter - // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. - const [focusChannelName, setFocusChannelName] = useState(null); - const [focusChannelToken, setFocusChannelToken] = useState(0); - const focusChannel = (name: string) => { - setFocusChannelName(name); - setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused - setChannelsView("subscribed"); // the name filter applies to the subscriptions table - 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); - }, [page]); function openReleaseNotes(highlight?: string) { setNotesHighlight(highlight); @@ -546,7 +505,6 @@ export default function App() { setPanelLayout("feed", l)} - onFocusChannel={focusChannel} isDemo={meQuery.data!.is_demo} collapsed={filterCollapsed} onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} @@ -607,18 +565,7 @@ export default function App() { // fade would sit on it and flip on with the first scroll, so turn it off there. fadeTop={page !== "channels" && page !== "audit"} header={ -
{ - setChannelFilter("needs_full"); - setChannelsView("subscribed"); // the status filter applies to the subscriptions tab - setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows - setPage("channels"); - }} - /> +
} banners={ <> @@ -629,27 +576,7 @@ export default function App() { > {page === "channels" ? ( - setWizardOpen(true)} - onViewChannel={(id, name) => openChannel(id, name)} - onFilterByTag={(tagId, name) => { - setFilters({ ...filters, tags: [tagId], channelIds: [] }); - setPage("feed"); - notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); - }} - /> + setWizardOpen(true)} /> ) : page === "stats" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( @@ -663,7 +590,7 @@ export default function App() { ) : page === "playlists" ? ( ) : page === "notifications" ? ( - + ) : page === "messages" && !meQuery.data!.is_demo ? (
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ca9dcb06..3a19cdb8 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -15,7 +15,7 @@ import { UserMinus, X, } from "lucide-react"; -import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { api, type ManagedChannel, type Me, type Tag } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; @@ -30,6 +30,9 @@ import ChannelLink from "./ChannelLink"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; +import { useChannels, useChannelsActions } from "./ChannelsProvider"; +import { useNavigationActions } from "./NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; export type ChannelsView = "subscribed" | "discovery" | "blocked"; @@ -42,49 +45,36 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "hidden", labelKey: "channels.filters.hidden" }, ]; -export default function Channels({ - canWrite, - isAdmin, - search, - setSearch, - onViewChannel, - onFilterByTag, - onFocusChannel, - focusChannelName, - focusChannelToken, - statusFilter, - setStatusFilter, - view, - setView, - filtersResetToken, - onOpenWizard, -}: { - canWrite: boolean; - isAdmin: boolean; - // The channel-name filter, lifted to App so the shared top SearchBar drives it. - search: string; - setSearch: (q: string) => void; - onViewChannel: (id: string, name: string) => void; - onFilterByTag: (tagId: number, name: string) => void; - onFocusChannel: (name: string) => void; - focusChannelName: string | null; - // Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel - // re-seeds the search box even when the name is unchanged. - focusChannelToken: number; - statusFilter: ChannelStatusFilter; - setStatusFilter: (f: ChannelStatusFilter) => void; - // The active tab lives in App so navigation intents that target the subscriptions table - // (the header's "without full history" link, focus-channel) can force it back here. - view: ChannelsView; - setView: (v: ChannelsView) => void; - // Bumped by the header's "without full history" intent to clear a stale column filter. - filtersResetToken: number; - onOpenWizard: () => void; -}) { +export default function Channels({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { + const canWrite = me.can_write; + const isAdmin = me.role === "admin"; + // The manager's name filter, status chip, active tab, reset token, and focus-channel intent live + // in ChannelsProvider (shared with the header search box + the cross-module "focus this channel" + // callers); this page is their primary editor. + const { + search, + statusFilter, + view, + filtersResetToken, + focusName: focusChannelName, + focusToken: focusChannelToken, + } = useChannels(); + const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions(); + const { openChannel: onViewChannel, setPage } = useNavigationActions(); + const feedFilters = useFeedFilters(); + const { setFilters: setFeedFilters } = useFeedFiltersActions(); const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); + // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App + // callback) now that Channels reads the feed filters + navigation straight from their providers. + const onFilterByTag = (tagId: number, name: string) => { + setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] }); + setPage("feed"); + notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); + }; + const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx new file mode 100644 index 00000000..0ccdb6b1 --- /dev/null +++ b/frontend/src/components/ChannelsProvider.tsx @@ -0,0 +1,147 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { useNavigation, useNavigationActions } from "./NavigationProvider"; +import type { ChannelStatusFilter, ChannelsView } from "./Channels"; + +// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip, +// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of +// App so the Channels page, the header search box, and the cross-module callers (the feed filter +// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks +// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts, +// stable useCallback setters, one-line hooks; composed in main.tsx around ). +// +// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and +// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are +// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account +// switch does a full reload, so init-from-storage is all the hydration the persisted ones need. + +const STATUS_FILTERS: readonly ChannelStatusFilter[] = [ + "needs_full", + "fully_synced", + "hidden", + "all", +]; + +function clampStatus(v: string | null): ChannelStatusFilter { + return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all"; +} +function clampView(v: string | null): ChannelsView { + return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed"; +} + +type ChannelsState = { + search: string; + statusFilter: ChannelStatusFilter; + view: ChannelsView; + // Bumped to tell the manager to drop a stale column filter when an intent sends the user to a + // specific set (the header's "without full history" link). + filtersResetToken: number; + focusName: string | null; + // Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the + // manager's name filter even when the name is unchanged. + focusToken: number; +}; + +interface ChannelsActions { + setSearch: (q: string) => void; + setStatusFilter: (f: ChannelStatusFilter) => void; + setView: (v: ChannelsView) => void; + // "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name + // filter so the channel is isolated. Called from the feed sidebar and the notifications inbox. + focusChannel: (name: string) => void; + // Jump to the manager filtered to channels still missing full history (the header sync chip). + goToFullHistory: () => void; +} + +const StateContext = createContext({ + search: "", + statusFilter: "all", + view: "subscribed", + filtersResetToken: 0, + focusName: null, + focusToken: 0, +}); +const ActionsContext = createContext({ + setSearch: () => {}, + setStatusFilter: () => {}, + setView: () => {}, + focusChannel: () => {}, + goToFullHistory: () => {}, +}); + +export function useChannels(): ChannelsState { + return useContext(StateContext); +} +export function useChannelsActions(): ChannelsActions { + return useContext(ActionsContext); +} + +export function ChannelsProvider({ children }: { children: ReactNode }) { + const { page } = useNavigation(); + const { setPage } = useNavigationActions(); + + const [search, setSearchState] = useState(""); + const [statusFilter, setStatusFilterState] = useState(() => + clampStatus(getAccountRaw(LS.channelFilter)), + ); + const [view, setViewState] = useState(() => + clampView(getAccountRaw(LS.channelsView)), + ); + const [filtersResetToken, setFiltersResetToken] = useState(0); + const [focusName, setFocusName] = useState(null); + const [focusToken, setFocusToken] = useState(0); + + const setSearch = useCallback((q: string) => setSearchState(q), []); + const setStatusFilter = useCallback((f: ChannelStatusFilter) => { + setStatusFilterState(f); + setAccountRaw(LS.channelFilter, f); + }, []); + const setView = useCallback((v: ChannelsView) => { + setViewState(v); + setAccountRaw(LS.channelsView, v); + }, []); + + const focusChannel = useCallback( + (name: string) => { + setFocusName(name); + setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused + setView("subscribed"); // the name filter applies to the subscriptions table + setPage("channels"); + }, + [setView, setPage], + ); + const goToFullHistory = useCallback(() => { + setStatusFilter("needs_full"); + setView("subscribed"); // the status filter applies to the subscriptions tab + setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows + setPage("channels"); + }, [setStatusFilter, setView, setPage]); + + // Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit. + useEffect(() => { + if (page !== "channels") setFocusName(null); + }, [page]); + + const state = useMemo( + () => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }), + [search, statusFilter, view, filtersResetToken, focusName, focusToken], + ); + const actions = useMemo( + () => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }), + [setSearch, setStatusFilter, setView, focusChannel, goToFullHistory], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1cfc69be..14de9e6c 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -10,37 +10,34 @@ import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; import { usePlex, usePlexActions } from "./PlexProvider"; +import { useChannels, useChannelsActions } from "./ChannelsProvider"; // 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 // sync chip at the right end. export default function Header({ me, - channelsSearch, - setChannelsSearch, onYtSearch, - onGoToFullHistory, }: { me: Me; - channelsSearch: string; - setChannelsSearch: (q: string) => 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; - // Jump to the channel manager filtered to channels still missing full history. - onGoToFullHistory: () => void; }) { const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); - // The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane); the - // Plex search term lives in PlexProvider (shared with its rail + browse grid). + // Each module's search term lives in its own provider (shared with that module's rail/content): + // playlists name filter, Plex search term, channels name filter. The channel sync chip's + // "go to full history" jump is a ChannelsProvider action. const { search: playlistsSearch } = usePlaylists(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); const { q: plexQ } = usePlex(); const { setQ: setPlexQ } = usePlexActions(); + const { search: channelsSearch } = useChannels(); + const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions(); // 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. diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index e4aaed4c..6486ab76 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -23,6 +23,7 @@ import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { LEVEL_STYLE } from "./Toaster"; import { PageToolbar } from "./PageShell"; import { useNavigationActions } from "./NavigationProvider"; +import { useChannelsActions } from "./ChannelsProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // The single notification center. "System" = durable, server-backed notifications (inbox @@ -31,15 +32,14 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // there's one indicator and one place to look. Transient toasts still pop via the Toaster. const POLL_MS = 8000; -export default function NotificationsPanel({ - onFocusChannel, -}: { - onFocusChannel: (name: string) => void; -}) { +export default function NotificationsPanel() { const { t } = useTranslation(); const { setPage } = useNavigationActions(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); + // "Focus this channel in the manager" (from a new-subscription notification) lives in + // ChannelsProvider — read it here and pass it down to the notification rows. + const { focusChannel: onFocusChannel } = useChannelsActions(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( ["notifications"], diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2da3a3a1..11efebc8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -14,6 +14,7 @@ import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import ChannelPicker from "./ChannelPicker"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { useChannelsActions } from "./ChannelsProvider"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -63,14 +64,12 @@ function TagChip({ export default function Sidebar({ layout, setLayout, - onFocusChannel, isDemo = false, collapsed, onToggleCollapse, }: { layout: PanelLayout; setLayout: (l: PanelLayout) => void; - onFocusChannel: (name: string) => void; isDemo?: boolean; collapsed: boolean; onToggleCollapse: () => void; @@ -78,6 +77,8 @@ export default function Sidebar({ const { t } = useTranslation(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); + // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. + const { focusChannel: onFocusChannel } = useChannelsActions(); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3da3f344..a7445ab9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { NavigationProvider } from "./components/NavigationProvider"; import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { PlaylistsProvider } from "./components/PlaylistsProvider"; import { PlexProvider } from "./components/PlexProvider"; +import { ChannelsProvider } from "./components/ChannelsProvider"; import "./i18n"; import "./index.css"; @@ -43,7 +44,9 @@ const root = - + + +