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";
|
} from "./lib/api";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||||
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||||
|
import type { Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
@@ -27,13 +28,13 @@ import {
|
|||||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
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.
|
// Persistent shell — always mounted, so eagerly imported.
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
import SetupWizard from "./components/SetupWizard";
|
import SetupWizard from "./components/SetupWizard";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import NavSidebar from "./components/NavSidebar";
|
import NavSidebar from "./components/NavSidebar";
|
||||||
import Sidebar from "./components/Sidebar";
|
|
||||||
import PlaylistsRail from "./components/PlaylistsRail";
|
|
||||||
import BackToTop from "./components/BackToTop";
|
import BackToTop from "./components/BackToTop";
|
||||||
import PageShell from "./components/PageShell";
|
import PageShell from "./components/PageShell";
|
||||||
import GlassTuner from "./components/GlassTuner";
|
import GlassTuner from "./components/GlassTuner";
|
||||||
@@ -45,31 +46,17 @@ import DemoBanner from "./components/DemoBanner";
|
|||||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||||
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,
|
// The page modules (and their side rails) are code-split per route via the module registry
|
||||||
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
|
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||||
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
|
// two modals, which sit outside the registry-driven content column.
|
||||||
const Feed = lazy(() => import("./components/Feed"));
|
|
||||||
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
|
|
||||||
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
|
|
||||||
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
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 About = lazy(() => import("./components/About"));
|
||||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { page, channelView, ytSearch } = useNavigation();
|
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
|
// 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
|
// 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).
|
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||||
@@ -331,6 +318,16 @@ export default function App() {
|
|||||||
? `yt:${ytSearch}`
|
? `yt:${ytSearch}`
|
||||||
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
: `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 (
|
return (
|
||||||
<div className="h-screen flex text-fg">
|
<div className="h-screen flex text-fg">
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
@@ -341,36 +338,23 @@ export default function App() {
|
|||||||
collapsed={navCollapsed}
|
collapsed={navCollapsed}
|
||||||
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
|
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
|
||||||
/>
|
/>
|
||||||
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
|
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
|
||||||
content column. Sits beside the nav rail as its own collapsible column. */}
|
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
|
||||||
{page === "feed" && !channelView && (
|
content. feed + plex share the filter-rail collapse flag, playlists has its own. */}
|
||||||
<Sidebar
|
{railDef && (
|
||||||
layout={panelLayouts.feed}
|
|
||||||
setLayout={(l) => setPanelLayout("feed", l)}
|
|
||||||
isDemo={meQuery.data!.is_demo}
|
|
||||||
collapsed={filterCollapsed}
|
|
||||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{page === "plex" && meQuery.data!.plex_enabled && (
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PlexSidebar
|
<railDef.Rail
|
||||||
layout={panelLayouts.plex}
|
layout={panelLayouts[railDef.panelId]}
|
||||||
setLayout={(l) => setPanelLayout("plex", l)}
|
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
||||||
collapsed={filterCollapsed}
|
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
|
||||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
onToggleCollapse={() =>
|
||||||
|
railDef.collapse === "playlists"
|
||||||
|
? setPlaylistsCollapsed(!playlistsCollapsed)
|
||||||
|
: setFilterCollapsed(!filterCollapsed)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</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">
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||||
{channelView ? (
|
{channelView ? (
|
||||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
<PageShell scrollKey={`channel:${channelView.id}`}>
|
||||||
@@ -403,12 +387,8 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<PageShell
|
<PageShell
|
||||||
scrollKey={normalScrollKey}
|
scrollKey={normalScrollKey}
|
||||||
// Channels + Audit render a table with a sticky header at the scroller top; the top
|
fadeTop={fadeTop ?? true}
|
||||||
// fade would sit on it and flip on with the first scroll, so turn it off there.
|
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||||
fadeTop={page !== "channels" && page !== "audit"}
|
|
||||||
header={
|
|
||||||
<Header me={meQuery.data!} onYtSearch={enterYtSearch} />
|
|
||||||
}
|
|
||||||
banners={
|
banners={
|
||||||
<>
|
<>
|
||||||
{meQuery.data!.is_demo && <DemoBanner />}
|
{meQuery.data!.is_demo && <DemoBanner />}
|
||||||
@@ -417,45 +397,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
{page === "channels" ? (
|
<PageContent />
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Section } from "./ui/form";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
// 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
|
// 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
|
// (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.)
|
// 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 { t } = useTranslation();
|
||||||
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
@@ -34,6 +34,7 @@ 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";
|
import { useWizard } from "./WizardProvider";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
|||||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
{ 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 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).
|
// "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"));
|
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
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 { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
|
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
|
||||||
@@ -539,7 +540,8 @@ function AdminSystem() {
|
|||||||
|
|
||||||
// --- Main -----------------------------------------------------------------------------------
|
// --- Main -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function DownloadCenter({ me }: { me: Me }) {
|
export default function DownloadCenter() {
|
||||||
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Youtube,
|
Youtube,
|
||||||
} from "lucide-react";
|
} 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 { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
@@ -41,6 +41,7 @@ import UndoToolbar from "./UndoToolbar";
|
|||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
type SortKey = "manual" | "title" | "duration" | "channel";
|
type SortKey = "manual" | "title" | "duration" | "channel";
|
||||||
type SortDir = "asc" | "desc";
|
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;
|
const canWrite = me.can_write;
|
||||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||||
// list + its rail controls). This detail pane reacts to the selection.
|
// 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 { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
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 { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
@@ -11,6 +11,7 @@ import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
|||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
||||||
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
|
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
|
// 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.
|
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
|
||||||
export default function PlaylistsRail({
|
export default function PlaylistsRail({
|
||||||
me,
|
|
||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
|
||||||
layout: PanelLayout;
|
layout: PanelLayout;
|
||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
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
|
// 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.
|
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
||||||
const { search, selectedId } = usePlaylists();
|
const { search, selectedId } = usePlaylists();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
import { usePrefs } from "./PrefsProvider";
|
import { usePrefs } from "./PrefsProvider";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -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
|
// 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({ me }: { me: Me }) {
|
export default function SettingsPanel() {
|
||||||
|
const me = useMe();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
|
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
|
||||||
const prefs = usePrefs();
|
const prefs = usePrefs();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
|
|||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { useChannelsActions } from "./ChannelsProvider";
|
import { useChannelsActions } from "./ChannelsProvider";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
@@ -64,17 +65,16 @@ function TagChip({
|
|||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
isDemo = false,
|
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: {
|
}: {
|
||||||
layout: PanelLayout;
|
layout: PanelLayout;
|
||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
isDemo?: boolean;
|
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isDemo = useMe().is_demo;
|
||||||
const filters = useFeedFilters();
|
const filters = useFeedFilters();
|
||||||
const { setFilters } = useFeedFiltersActions();
|
const { setFilters } = useFeedFiltersActions();
|
||||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
// "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 { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { Section, SettingRow } from "./ui/form";
|
import { Section, SettingRow } from "./ui/form";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
const RANGES = [7, 30, 90] as const;
|
const RANGES = [7, 30, 90] as const;
|
||||||
type StatsTab = "overview" | "system";
|
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);
|
// 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
|
// "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.
|
// 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 { t } = useTranslation();
|
||||||
const isAdmin = me.role === "admin";
|
const isAdmin = me.role === "admin";
|
||||||
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
|
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