diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96d391c8..93f003b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 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 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 (
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 && ( - 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 && ( - setPanelLayout("plex", l)} - collapsed={filterCollapsed} - onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} + setPanelLayout(railDef.panelId, l)} + collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed} + onToggleCollapse={() => + railDef.collapse === "playlists" + ? setPlaylistsCollapsed(!playlistsCollapsed) + : setFilterCollapsed(!filterCollapsed) + } /> )} - {page === "playlists" && !channelView && ( - setPanelLayout("playlists", l)} - collapsed={playlistsCollapsed} - onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)} - /> - )}
{channelView ? ( @@ -403,12 +387,8 @@ export default function App() { ) : ( - } + fadeTop={fadeTop ?? true} + header={
} banners={ <> {meQuery.data!.is_demo && } @@ -417,45 +397,7 @@ export default function App() { } > - {page === "channels" ? ( - - ) : page === "stats" ? ( - - ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( - - ) : page === "config" && meQuery.data!.role === "admin" ? ( - - ) : page === "users" && meQuery.data!.role === "admin" ? ( - - ) : page === "audit" && meQuery.data!.role === "admin" ? ( - - ) : page === "playlists" ? ( - - ) : page === "notifications" ? ( - - ) : page === "messages" && !meQuery.data!.is_demo ? ( -
- -
- ) : page === "downloads" && !meQuery.data!.is_demo ? ( - - ) : page === "settings" ? ( - - ) : page === "plex" && meQuery.data!.plex_enabled ? ( - - ) : ( - - )} +
)} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index e2fca998..ce618225 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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 = [ diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 87138896..93a69c46 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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). diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index a2c36647..5d734340 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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(); diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index cd7575bf..5e2c9fc2 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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. diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 1dd85c4e..d7f09d21 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -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(); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index c2a5f978..36bb30fb 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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(); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 11efebc8..126ae058 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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.") 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. diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 74ff8556..4c22d383 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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"); diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx new file mode 100644 index 00000000..80eecfa1 --- /dev/null +++ b/frontend/src/components/moduleRegistry.tsx @@ -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 `` 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 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 . +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 to the app-wide feed filters / view / live-search. (The channel +// page renders 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 ( + + ); +} + +function MessagesPage() { + const me = useMe(); + return ( +
+ +
+ ); +} + +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 = { + 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; + // 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> = { + feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" }, + plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" }, + playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" }, +}; diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts new file mode 100644 index 00000000..dd5d7809 --- /dev/null +++ b/frontend/src/lib/useMe.ts @@ -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; +}