Every page's floating header (search bar + sync chip) and the toolbar band now centre on the SAME max-width the content column uses, instead of spanning the whole column while a max-w page centres its grid. So the search bar lines up with the left edge of the cards/table on every page, and sits centred with the content — not against the full page width. - PageShell takes a `maxWidth`, published as --content-mw; the header adds max-w-[--content-mw] mx-auto inside its left-4 / right-(1rem+scrollbar) span, so it centres on that width (`none` = full width). - moduleRegistry PAGE_CONTENT carries each page's content max width (plex 1600px, channels 96rem, playlists 64rem, downloads 56rem, notifications/users 48rem; feed stays full width) — one source. - Playlists: its content (header, filter row, table) was left-aligned max-w-5xl → now mx-auto, so the table is centred and the header lines up with it. Verified at 1920px: header left edge == content left edge — feed 340, channels 213, playlists 487.
126 lines
5.5 KiB
TypeScript
126 lines
5.5 KiB
TypeScript
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;
|
|
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
|
|
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
|
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
|
maxWidth?: string;
|
|
};
|
|
|
|
// 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, maxWidth: "96rem" },
|
|
playlists: { Component: Playlists, maxWidth: "64rem" },
|
|
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
|
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
|
messages: { Component: MessagesPage },
|
|
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
|
stats: { Component: Stats },
|
|
scheduler: { Component: Scheduler },
|
|
config: { Component: ConfigPanel },
|
|
users: { Component: AdminUsers, maxWidth: "48rem" },
|
|
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" },
|
|
};
|