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:
+33
-91
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user