From dd921e3209809a3e71ca8c580dfba3356edf1198 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 00:55:34 +0200 Subject: [PATCH] refactor(wizard): move onboarding wizard state into WizardProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the onboarding wizard's open state, first-login auto-open effect, and the wizard render itself out of App into WizardProvider. Feed, Channels, and SettingsPanel trigger it via useWizard() instead of an onOpenWizard prop; App and ChannelPage drop the prop plumbing. First step of S6b.3 — removing the per-module props that block a uniform registry render. (Platform S6b.3) --- frontend/src/App.tsx | 22 +------- frontend/src/components/ChannelPage.tsx | 1 - frontend/src/components/Channels.tsx | 5 +- frontend/src/components/Feed.tsx | 10 ++-- frontend/src/components/SettingsPanel.tsx | 15 ++--- frontend/src/components/WizardProvider.tsx | 66 ++++++++++++++++++++++ frontend/src/main.tsx | 5 +- 7 files changed, 87 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/WizardProvider.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1db9050..a2227df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,7 +53,6 @@ import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; import DemoBanner from "./components/DemoBanner"; import { focusAccessRequestsTab } from "./lib/adminUsersTab"; -import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import { CURRENT_VERSION } from "./lib/releaseNotes"; // Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk, @@ -74,7 +73,6 @@ const SettingsPanel = lazy(() => import("./components/SettingsPanel")); const NotificationsPanel = lazy(() => import("./components/NotificationsPanel")); const Messages = lazy(() => import("./components/Messages")); const DownloadCenter = lazy(() => import("./components/DownloadCenter")); -const OnboardingWizard = lazy(() => import("./components/OnboardingWizard")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); @@ -127,7 +125,6 @@ export default function App() { plex: loadLayout("plex"), playlists: loadLayout("playlists"), })); - const [wizardOpen, setWizardOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -337,13 +334,6 @@ export default function App() { stripUrlParams(); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps - // First-login onboarding: prompt the user to connect YouTube (and resume the flow after - // each consent redirect). Derived from granted scopes + storage flags so it's stable - // across the full-page OAuth round-trip; dismissible and reopenable from Settings. - useEffect(() => { - if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true); - }, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]); - // On login, adopt server-stored preferences. useEffect(() => { const prefs = meQuery.data?.preferences; @@ -576,7 +566,7 @@ export default function App() { > {page === "channels" ? ( - setWizardOpen(true)} /> + ) : page === "stats" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( @@ -598,11 +588,7 @@ export default function App() { ) : page === "downloads" && !meQuery.data!.is_demo ? ( ) : page === "settings" ? ( - setWizardOpen(true)} - /> + ) : page === "plex" && meQuery.data!.plex_enabled ? ( ) : ( @@ -613,7 +599,6 @@ export default function App() { setView={setView} canRead={meQuery.data!.can_read} isDemo={meQuery.data!.is_demo} - onOpenWizard={() => setWizardOpen(true)} ytSearch={ytSearch} onYtSearch={enterYtSearch} onExitYtSearch={exitYtSearch} @@ -629,9 +614,6 @@ export default function App() { {meQuery.data && !meQuery.data.is_demo && } - {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( - setWizardOpen(false)} /> - )} {aboutOpen && ( setAboutOpen(false)} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 17cc45d..ad549bc 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -398,7 +398,6 @@ export default function ChannelPage({ setView={setView} canRead={me.can_read} isDemo={me.is_demo} - onOpenWizard={() => {}} ytSearch={null} onYtSearch={() => {}} onExitYtSearch={() => {}} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3a19cdb..8713889 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -33,6 +33,7 @@ import { useConfirm } from "./ConfirmProvider"; import { useChannels, useChannelsActions } from "./ChannelsProvider"; import { useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { useWizard } from "./WizardProvider"; export type ChannelsView = "subscribed" | "discovery" | "blocked"; @@ -45,9 +46,11 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "hidden", labelKey: "channels.filters.hidden" }, ]; -export default function Channels({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { +export default function Channels({ me }: { me: Me }) { const canWrite = me.can_write; const isAdmin = me.role === "admin"; + // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA). + const { openWizard: onOpenWizard } = useWizard(); // 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. diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index c0b10d1..af09312 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -32,6 +32,7 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import { PageToolbar } from "./PageShell"; +import { useWizard } from "./WizardProvider"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. @@ -85,7 +86,6 @@ export default function Feed({ setView, canRead, isDemo = false, - onOpenWizard, ytSearch, onYtSearch, onExitYtSearch, @@ -93,13 +93,13 @@ export default function Feed({ }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; - // The view mode is an account pref, not a filter: App owns it and it persists on pick, so it - // isn't part of FeedFilters (no share-link param, no saved view, no active-filter chip). + // The view mode is an account pref, not a filter: it persists on pick, so it isn't part of + // FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller + // (FeedViewProvider on the feed page; ChannelPage threads its own). view: FeedView; setView: (v: FeedView) => void; canRead: boolean; isDemo?: boolean; - onOpenWizard: () => void; // Live YouTube search: the active search term (null = normal feed). Entering a search and // leaving it both step browser history (the search is a feed sub-view with its own history // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via @@ -112,6 +112,8 @@ export default function Feed({ channelScoped?: boolean; }) { const { t } = useTranslation(); + // "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected). + const { openWizard: onOpenWizard } = useWizard(); // Same list the panel's badge counts — see lib/useActiveFilters. const activeFilters = useActiveFilters(filters, setFilters); const sortKeys = channelScoped diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index dc44ac5..2e9fdbe 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -11,6 +11,7 @@ import Tooltip from "./Tooltip"; import { Section, SettingRow, Switch } from "./ui/form"; import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; +import { useWizard } from "./WizardProvider"; // The Settings page edits server-persisted preferences. Each change applies locally for instant // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the @@ -39,16 +40,10 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [ // Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps // the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is // shown by the Header; the tab rail stays as in-page sub-navigation. -export default function SettingsPanel({ - me, - prefs, - onOpenWizard, -}: { - me: Me; - prefs: PrefsController; - onOpenWizard: () => void; -}) { +export default function SettingsPanel({ me, prefs }: { me: Me; prefs: PrefsController }) { const { t } = useTranslation(); + // "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button). + const { openWizard } = useWizard(); const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance"); // Clamp at render so a stale/removed tab id falls back to Appearance. const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) @@ -87,7 +82,7 @@ export default function SettingsPanel({
{tab === "appearance" && } {tab === "notifications" && } - {tab === "account" && } + {tab === "account" && }
diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx new file mode 100644 index 0000000..c9cd42c --- /dev/null +++ b/frontend/src/components/WizardProvider.tsx @@ -0,0 +1,66 @@ +import { + createContext, + lazy, + Suspense, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { shouldAutoOpenOnboarding } from "../lib/onboarding"; + +const OnboardingWizard = lazy(() => import("./OnboardingWizard")); + +// Owns the onboarding wizard's open state (and the wizard render itself), lifted out of App so any +// module can trigger "connect YouTube" via a hook instead of an onOpenWizard prop threaded through +// the tree. Mirrors the other split-context providers; composed in main.tsx around . +// +// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from +// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from +// Settings / the notify "reconnect" action. It reads `me` as a passive ["me"] observer (App owns the +// fetching query), so it re-runs the auto-open check when the account resolves or its grants change. + +interface WizardActions { + openWizard: () => void; + closeWizard: () => void; +} + +// Only the actions are shared — the open flag stays local to the provider (it renders the wizard +// itself), so no consumer needs to subscribe to it. +const ActionsContext = createContext({ openWizard: () => {}, closeWizard: () => {} }); + +export function useWizard(): WizardActions { + return useContext(ActionsContext); +} + +export function WizardProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). + const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + + const openWizard = useCallback(() => setOpen(true), []); + const closeWizard = useCallback(() => setOpen(false), []); + + // First-login onboarding: prompt the user to connect YouTube (and resume the flow after each + // consent redirect). Re-checked when the account or its grants change. + useEffect(() => { + if (me && shouldAutoOpenOnboarding(me)) setOpen(true); + }, [me?.id, me?.can_read, me?.can_write]); + + const actions = useMemo(() => ({ openWizard, closeWizard }), [openWizard, closeWizard]); + + return ( + + {children} + {/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never + opens there. Lazy — its chunk loads only when first opened. */} + + {open && me && !me.is_demo && } + + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a7445ab..fc2b7fa 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { PlaylistsProvider } from "./components/PlaylistsProvider"; import { PlexProvider } from "./components/PlexProvider"; import { ChannelsProvider } from "./components/ChannelsProvider"; +import { WizardProvider } from "./components/WizardProvider"; import "./i18n"; import "./index.css"; @@ -45,7 +46,9 @@ const root = - + + +