From 1801a58f6bb1d981e123f1bae94821a63afb351f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 21 Aug 2026 21:38:49 +0200 Subject: [PATCH] feat(layout): centre the header on each page's content width, centre the playlist table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 3 ++- frontend/src/components/Header.tsx | 8 +++++--- frontend/src/components/PageShell.tsx | 13 +++++++++++++ frontend/src/components/Playlists.tsx | 4 ++-- frontend/src/components/moduleRegistry.tsx | 16 ++++++++++------ frontend/src/index.css | 5 +++++ 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f625ea6..9ec6a00d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -313,7 +313,7 @@ export default function App() { 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 { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage]; const railDef = channelView ? undefined : PAGE_RAIL[activePage]; return ( @@ -379,6 +379,7 @@ export default function App() { ) : ( {search} {/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index ad7548ff..61236b79 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -52,6 +52,7 @@ export default function PageShell({ children, scrollKey, fadeTop = true, + maxWidth, }: { header?: ReactNode; banners?: ReactNode; @@ -60,8 +61,20 @@ export default function PageShell({ scrollKey?: string; // Off when this page's scroll region starts with a sticky header — see PageScroller. fadeTop?: boolean; + // The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via + // --content-mw. Omit for a full-width page (feed). See index.css --content-mw. + maxWidth?: string; }) { const [toolbarSlot, setToolbarSlot] = useState(null); + // Publish this page's content max-width so the floating header (and toolbar band) can centre on the + // same width the content column uses. Global (one page shell renders at a time). + useLayoutEffect(() => { + const root = document.documentElement; + root.style.setProperty("--content-mw", maxWidth ?? "none"); + return () => { + root.style.removeProperty("--content-mw"); + }; + }, [maxWidth]); // The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL // height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad // would let the content toolbar slide up under it (E5 S7). Measure the header and write its height diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index fc7a9a63..466d8c8a 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -571,7 +571,7 @@ export default function Playlists() { {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's fixed band, so only the video list scrolls. */} -
+
{items[0]?.thumbnail_url && ( @@ -757,7 +757,7 @@ export default function Playlists() { accessibility={{ screenReaderInstructions, announcements }} > {/* Not clipped, so the header's filter popovers can overflow the table box. */} -
+
{/* table-fixed, not auto: every column but the title has a declared width, so the title absorbs the rest and its `truncate` works. Auto layout sizes columns from min-content, and a nowrap title cell's min-content is the entire title. */} diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx index 80eecfa1..a14a440d 100644 --- a/frontend/src/components/moduleRegistry.tsx +++ b/frontend/src/components/moduleRegistry.tsx @@ -75,21 +75,25 @@ export type PageDef = { // 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 = { feed: { Component: FeedPage }, - channels: { Component: Channels, fadeTop: false }, - playlists: { Component: Playlists }, - plex: { Component: PlexBrowse }, - notifications: { Component: NotificationsPanel }, + 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 }, + downloads: { Component: DownloadCenter, maxWidth: "56rem" }, stats: { Component: Stats }, scheduler: { Component: Scheduler }, config: { Component: ConfigPanel }, - users: { Component: AdminUsers }, + users: { Component: AdminUsers, maxWidth: "48rem" }, audit: { Component: AuditLog, fadeTop: false }, settings: { Component: SettingsPanel }, }; diff --git a/frontend/src/index.css b/frontend/src/index.css index c90dfd05..78ec681a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -104,6 +104,11 @@ Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */ --sbw: 0px; + /* Max width of a page's content column (cards / table). The floating header + toolbar band read it + so they centre on the SAME max-width the content uses, instead of spanning the whole column while + a max-w page centres its grid. Each page sets it via PageShell's `maxWidth`; `none` = full width. */ + --content-mw: none; + /* ===== Glass tunables — every value that materially drives the look lives here so it can be tuned in ONE place. ===== */ /* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome