feat(app): the module registry drives the render (S6b DoD)

Replace App's page-render ternary and the three side-rail page conditionals with
a data-driven lookup: components/moduleRegistry maps each page to a prop-free
content component (+ optional side rail + shell fadeTop), and App renders
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate (an unreachable
gated page falls back to the feed, as the old ternary did). Content modules and
the two rails are now prop-free: they read `me` via the new useMe() hook and
their module-local state from the scoped providers; FeedPage/MessagesPage are the
only wrappers (they wire the global feed scope / meId). App.tsx 796->431 lines,
zero `page===X` chrome conditionals — closes the Platform epic DoD. (Platform S6b.3)
This commit is contained in:
2026-07-19 01:25:32 +02:00
parent fd1742e783
commit a42e0a9e3e
11 changed files with 193 additions and 106 deletions
+33 -91
View File
@@ -9,6 +9,7 @@ import {
} from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
import {
loadLayout,
normalizeLayout,
@@ -27,13 +28,13 @@ import {
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { moduleDef } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop";
import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner";
@@ -45,31 +46,17 @@ import DemoBanner from "./components/DemoBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { CURRENT_VERSION } from "./lib/releaseNotes";
// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
const Feed = lazy(() => import("./components/Feed"));
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const Channels = lazy(() => import("./components/Channels"));
const Playlists = lazy(() => import("./components/Playlists"));
const Stats = lazy(() => import("./components/Stats"));
const Scheduler = lazy(() => import("./components/Scheduler"));
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
const AdminUsers = lazy(() => import("./components/AdminUsers"));
const AuditLog = lazy(() => import("./components/AuditLog"));
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 About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions();
const { setPage, enterYtSearch } = useNavigationActions();
// 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 <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
@@ -331,6 +318,16 @@ export default function App() {
? `yt:${ytSearch}`
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
// side rail shows behind it.
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
<div className="h-screen flex text-fg">
<NavSidebar
@@ -341,36 +338,23 @@ export default function App() {
collapsed={navCollapsed}
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
/>
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
content column. Sits beside the nav rail as its own collapsible column. */}
{page === "feed" && !channelView && (
<Sidebar
layout={panelLayouts.feed}
setLayout={(l) => setPanelLayout("feed", l)}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
)}
{page === "plex" && meQuery.data!.plex_enabled && (
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
content. feed + plex share the filter-rail collapse flag, playlists has its own. */}
{railDef && (
<Suspense fallback={null}>
<PlexSidebar
layout={panelLayouts.plex}
setLayout={(l) => setPanelLayout("plex", l)}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
<railDef.Rail
layout={panelLayouts[railDef.panelId]}
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
onToggleCollapse={() =>
railDef.collapse === "playlists"
? setPlaylistsCollapsed(!playlistsCollapsed)
: setFilterCollapsed(!filterCollapsed)
}
/>
</Suspense>
)}
{page === "playlists" && !channelView && (
<PlaylistsRail
me={meQuery.data!}
layout={panelLayouts.playlists}
setLayout={(l) => setPanelLayout("playlists", l)}
collapsed={playlistsCollapsed}
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
/>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
@@ -403,12 +387,8 @@ export default function App() {
) : (
<PageShell
scrollKey={normalScrollKey}
// Channels + Audit render a table with a sticky header at the scroller top; the top
// fade would sit on it and flip on with the first scroll, so turn it off there.
fadeTop={page !== "channels" && page !== "audit"}
header={
<Header me={meQuery.data!} onYtSearch={enterYtSearch} />
}
fadeTop={fadeTop ?? true}
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
{meQuery.data!.is_demo && <DemoBanner />}
@@ -417,45 +397,7 @@ export default function App() {
}
>
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels me={meQuery.data!} />
) : page === "stats" ? (
<Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler />
) : page === "config" && meQuery.data!.role === "admin" ? (
<ConfigPanel />
) : page === "users" && meQuery.data!.role === "admin" ? (
<AdminUsers me={meQuery.data!} />
) : page === "audit" && meQuery.data!.role === "admin" ? (
<AuditLog />
) : page === "playlists" ? (
<Playlists me={meQuery.data!} />
) : page === "notifications" ? (
<NotificationsPanel />
) : page === "messages" && !meQuery.data!.is_demo ? (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
</div>
) : page === "downloads" && !meQuery.data!.is_demo ? (
<DownloadCenter me={meQuery.data!} />
) : page === "settings" ? (
<SettingsPanel me={meQuery.data!} />
) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse />
) : (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
)}
<PageContent />
</Suspense>
</PageShell>
)}
+3 -1
View File
@@ -10,6 +10,7 @@ import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import { useMe } from "../lib/useMe";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -17,7 +18,8 @@ import { PageToolbar } from "./PageShell";
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.)
export default function AdminUsers({ me }: { me: Me }) {
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
const tabs = [
+4 -2
View File
@@ -15,7 +15,7 @@ import {
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Me, type Tag } from "../lib/api";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
@@ -34,6 +34,7 @@ import { useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useWizard } from "./WizardProvider";
import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery" | "blocked";
@@ -46,7 +47,8 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "hidden", labelKey: "channels.filters.hidden" },
];
export default function Channels({ me }: { me: Me }) {
export default function Channels() {
const me = useMe();
const canWrite = me.can_write;
const isAdmin = me.role === "admin";
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
+4 -2
View File
@@ -27,7 +27,8 @@ const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
@@ -539,7 +540,8 @@ function AdminSystem() {
// --- Main -----------------------------------------------------------------------------------
export default function DownloadCenter({ me }: { me: Me }) {
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
+4 -2
View File
@@ -31,7 +31,7 @@ import {
X,
Youtube,
} from "lucide-react";
import { api, type Me, type Video } from "../lib/api";
import { api, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -41,6 +41,7 @@ import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc";
@@ -179,7 +180,8 @@ function Row({
);
}
export default function Playlists({ me }: { me: Me }) {
export default function Playlists() {
const me = useMe();
const canWrite = me.can_write;
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
+3 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Me, type Playlist } from "../lib/api";
import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -11,6 +11,7 @@ import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
@@ -20,19 +21,17 @@ const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
export default function PlaylistsRail({
me,
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
me: Me;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const canWrite = me.can_write;
const canWrite = useMe().can_write;
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
// box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists();
+3 -1
View File
@@ -13,6 +13,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { useWizard } from "./WizardProvider";
import { usePrefs } from "./PrefsProvider";
import { useMe } from "../lib/useMe";
// 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
@@ -41,7 +42,8 @@ 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 }: { me: Me }) {
export default function SettingsPanel() {
const me = useMe();
const { t } = useTranslation();
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
const prefs = usePrefs();
+2 -2
View File
@@ -15,6 +15,7 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
@@ -64,17 +65,16 @@ function TagChip({
export default function Sidebar({
layout,
setLayout,
isDemo = false,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
isDemo?: boolean;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
const isDemo = useMe().is_demo;
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
+3 -1
View File
@@ -8,6 +8,7 @@ import { notify } from "../lib/notifications";
import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system";
@@ -15,7 +16,8 @@ type StatsTab = "overview" | "system";
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
export default function Stats({ me }: { me: Me }) {
export default function Stats() {
const me = useMe();
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
+121
View File
@@ -0,0 +1,121 @@
import { lazy, type ComponentType } from "react";
import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout";
import { useMe } from "../lib/useMe";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useFeedView, useFeedViewActions } from "./FeedViewProvider";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
// `page === X` chrome conditionals.
//
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
// wiring get a thin wrapper here (FeedPage supplies the GLOBAL feed scope; the channel page threads
// its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
const Feed = lazy(() => import("./Feed"));
const Channels = lazy(() => import("./Channels"));
const Playlists = lazy(() => import("./Playlists"));
const PlexBrowse = lazy(() => import("./PlexBrowse"));
const PlexSidebar = lazy(() => import("./PlexSidebar"));
const NotificationsPanel = lazy(() => import("./NotificationsPanel"));
const Messages = lazy(() => import("./Messages"));
const DownloadCenter = lazy(() => import("./DownloadCenter"));
const Stats = lazy(() => import("./Stats"));
const Scheduler = lazy(() => import("./Scheduler"));
const ConfigPanel = lazy(() => import("./ConfigPanel"));
const AdminUsers = lazy(() => import("./AdminUsers"));
const AuditLog = lazy(() => import("./AuditLog"));
const SettingsPanel = lazy(() => import("./SettingsPanel"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
function FeedPage() {
const me = useMe();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const view = useFeedView();
const { setView } = useFeedViewActions();
const { ytSearch } = useNavigation();
const { enterYtSearch, exitYtSearch } = useNavigationActions();
return (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
);
}
function MessagesPage() {
const me = useMe();
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={me.id} />
</div>
);
}
export type PageDef = {
// Prop-free by construction: reads `me` + module state from hooks/providers.
Component: ComponentType;
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false },
playlists: { Component: Playlists },
plex: { Component: PlexBrowse },
notifications: { Component: NotificationsPanel },
messages: { Component: MessagesPage },
downloads: { Component: DownloadCenter },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers },
audit: { Component: AuditLog, fadeTop: false },
settings: { Component: SettingsPanel },
};
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
// its provider and `me` from useMe().
type RailProps = {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
type RailCollapseKey = "filter" | "playlists";
export type RailDef = {
Rail: ComponentType<RailProps>;
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
panelId: PanelId;
collapse: RailCollapseKey;
};
// page id → its side rail (only the three pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
};
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { api, type Me } from "./api";
// The signed-in account, read from the ["me"] query cache. App owns the FETCHING query (gated on
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
// instead of having it prop-drilled through App.
export function useMe(): Me {
const { data } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
return data as Me;
}