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:
2026-07-19 00:55:34 +02:00
parent 8434aac78d
commit dd921e3209
7 changed files with 87 additions and 37 deletions
-1
View File
@@ -398,7 +398,6 @@ export default function ChannelPage({
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
onOpenWizard={() => {}}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
+4 -1
View File
@@ -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.
+6 -4
View File
@@ -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
+5 -10
View File
@@ -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({
<div className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
</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>
);
}