refactor(wizard): move onboarding wizard state into WizardProvider
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)
This commit is contained in:
+2
-20
@@ -53,7 +53,6 @@ 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 { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
|
||||||
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
||||||
|
|
||||||
// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
|
// 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 NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
|
||||||
const Messages = lazy(() => import("./components/Messages"));
|
const Messages = lazy(() => import("./components/Messages"));
|
||||||
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
|
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
|
||||||
const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
|
|
||||||
const About = lazy(() => import("./components/About"));
|
const About = lazy(() => import("./components/About"));
|
||||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
||||||
|
|
||||||
@@ -127,7 +125,6 @@ export default function App() {
|
|||||||
plex: loadLayout("plex"),
|
plex: loadLayout("plex"),
|
||||||
playlists: loadLayout("playlists"),
|
playlists: loadLayout("playlists"),
|
||||||
}));
|
}));
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [notesOpen, setNotesOpen] = useState(false);
|
const [notesOpen, setNotesOpen] = useState(false);
|
||||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||||
@@ -337,13 +334,6 @@ export default function App() {
|
|||||||
stripUrlParams();
|
stripUrlParams();
|
||||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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.
|
// On login, adopt server-stored preferences.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prefs = meQuery.data?.preferences;
|
const prefs = meQuery.data?.preferences;
|
||||||
@@ -576,7 +566,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
{page === "channels" ? (
|
{page === "channels" ? (
|
||||||
<Channels me={meQuery.data!} onOpenWizard={() => setWizardOpen(true)} />
|
<Channels me={meQuery.data!} />
|
||||||
) : page === "stats" ? (
|
) : page === "stats" ? (
|
||||||
<Stats me={meQuery.data!} />
|
<Stats me={meQuery.data!} />
|
||||||
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
||||||
@@ -598,11 +588,7 @@ export default function App() {
|
|||||||
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
||||||
<DownloadCenter me={meQuery.data!} />
|
<DownloadCenter me={meQuery.data!} />
|
||||||
) : page === "settings" ? (
|
) : page === "settings" ? (
|
||||||
<SettingsPanel
|
<SettingsPanel me={meQuery.data!} prefs={prefsCtl} />
|
||||||
me={meQuery.data!}
|
|
||||||
prefs={prefsCtl}
|
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
|
||||||
/>
|
|
||||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||||
<PlexBrowse />
|
<PlexBrowse />
|
||||||
) : (
|
) : (
|
||||||
@@ -613,7 +599,6 @@ export default function App() {
|
|||||||
setView={setView}
|
setView={setView}
|
||||||
canRead={meQuery.data!.can_read}
|
canRead={meQuery.data!.can_read}
|
||||||
isDemo={meQuery.data!.is_demo}
|
isDemo={meQuery.data!.is_demo}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
|
||||||
ytSearch={ytSearch}
|
ytSearch={ytSearch}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
onExitYtSearch={exitYtSearch}
|
onExitYtSearch={exitYtSearch}
|
||||||
@@ -629,9 +614,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
|
||||||
)}
|
|
||||||
{aboutOpen && (
|
{aboutOpen && (
|
||||||
<About
|
<About
|
||||||
onClose={() => setAboutOpen(false)}
|
onClose={() => setAboutOpen(false)}
|
||||||
|
|||||||
@@ -398,7 +398,6 @@ export default function ChannelPage({
|
|||||||
setView={setView}
|
setView={setView}
|
||||||
canRead={me.can_read}
|
canRead={me.can_read}
|
||||||
isDemo={me.is_demo}
|
isDemo={me.is_demo}
|
||||||
onOpenWizard={() => {}}
|
|
||||||
ytSearch={null}
|
ytSearch={null}
|
||||||
onYtSearch={() => {}}
|
onYtSearch={() => {}}
|
||||||
onExitYtSearch={() => {}}
|
onExitYtSearch={() => {}}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
|
import { useWizard } from "./WizardProvider";
|
||||||
|
|
||||||
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
||||||
|
|
||||||
@@ -45,9 +46,11 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
|||||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
{ 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 canWrite = me.can_write;
|
||||||
const isAdmin = me.role === "admin";
|
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
|
// 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"
|
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
|
||||||
// callers); this page is their primary editor.
|
// callers); this page is their primary editor.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
|||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
|
import { useWizard } from "./WizardProvider";
|
||||||
import ViewSwitcher from "./ViewSwitcher";
|
import ViewSwitcher from "./ViewSwitcher";
|
||||||
import VirtualFeed from "./VirtualFeed";
|
import VirtualFeed from "./VirtualFeed";
|
||||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
// 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,
|
setView,
|
||||||
canRead,
|
canRead,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
onOpenWizard,
|
|
||||||
ytSearch,
|
ytSearch,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
onExitYtSearch,
|
onExitYtSearch,
|
||||||
@@ -93,13 +93,13 @@ export default function Feed({
|
|||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
// The view mode is an account pref, not a filter: App owns it and it persists on pick, so it
|
// The view mode is an account pref, not a filter: it persists on pick, so it isn't part of
|
||||||
// isn't part of FeedFilters (no share-link param, no saved view, no active-filter chip).
|
// 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;
|
view: FeedView;
|
||||||
setView: (v: FeedView) => void;
|
setView: (v: FeedView) => void;
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
onOpenWizard: () => void;
|
|
||||||
// Live YouTube search: the active search term (null = normal feed). Entering a search and
|
// 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
|
// 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
|
// 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;
|
channelScoped?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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.
|
// Same list the panel's badge counts — see lib/useActiveFilters.
|
||||||
const activeFilters = useActiveFilters(filters, setFilters);
|
const activeFilters = useActiveFilters(filters, setFilters);
|
||||||
const sortKeys = channelScoped
|
const sortKeys = channelScoped
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Tooltip from "./Tooltip";
|
|||||||
import { Section, SettingRow, Switch } from "./ui/form";
|
import { Section, SettingRow, Switch } from "./ui/form";
|
||||||
import { type SaveState } from "./ui/DraftSaveBar";
|
import { type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { useWizard } from "./WizardProvider";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
// 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
|
// 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
|
// 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
|
// 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.
|
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||||
export default function SettingsPanel({
|
export default function SettingsPanel({ me, prefs }: { me: Me; prefs: PrefsController }) {
|
||||||
me,
|
|
||||||
prefs,
|
|
||||||
onOpenWizard,
|
|
||||||
}: {
|
|
||||||
me: Me;
|
|
||||||
prefs: PrefsController;
|
|
||||||
onOpenWizard: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
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");
|
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
|
||||||
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
||||||
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
||||||
@@ -87,7 +82,7 @@ export default function SettingsPanel({
|
|||||||
<div className="flex-1 min-w-0 p-4">
|
<div className="flex-1 min-w-0 p-4">
|
||||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||||
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 <App/>.
|
||||||
|
//
|
||||||
|
// 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<WizardActions>({ 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<WizardActions>(() => ({ openWizard, closeWizard }), [openWizard, closeWizard]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionsContext.Provider value={actions}>
|
||||||
|
{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. */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
|
||||||
|
</Suspense>
|
||||||
|
</ActionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
|||||||
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
||||||
import { PlexProvider } from "./components/PlexProvider";
|
import { PlexProvider } from "./components/PlexProvider";
|
||||||
import { ChannelsProvider } from "./components/ChannelsProvider";
|
import { ChannelsProvider } from "./components/ChannelsProvider";
|
||||||
|
import { WizardProvider } from "./components/WizardProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -45,7 +46,9 @@ const root =
|
|||||||
<PlaylistsProvider>
|
<PlaylistsProvider>
|
||||||
<PlexProvider>
|
<PlexProvider>
|
||||||
<ChannelsProvider>
|
<ChannelsProvider>
|
||||||
<App />
|
<WizardProvider>
|
||||||
|
<App />
|
||||||
|
</WizardProvider>
|
||||||
</ChannelsProvider>
|
</ChannelsProvider>
|
||||||
</PlexProvider>
|
</PlexProvider>
|
||||||
</PlaylistsProvider>
|
</PlaylistsProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user