From dc6e62e8228a52595bf842e5cf420f9f6a0d12de Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 03:15:41 +0200 Subject: [PATCH 01/16] refactor(shell): introduce PageShell for the normal-page chrome Collapse App's hand-assembled normal-page chrome (floating Header + top-padded wrapper + PageScroller + banners) into a single PageShell contract with declared fixed vs scrollable regions. No behaviour change; the channel page still renders its own scroller (dissolved next). Groundwork for the S4 fixed-chrome/scroll-ownership seam. --- frontend/src/App.tsx | 62 +++++++++++++-------------- frontend/src/components/PageShell.tsx | 42 ++++++++++++++++++ 2 files changed, 72 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/PageShell.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c649dc4..d6f1405f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,7 +53,7 @@ import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import PlaylistsRail from "./components/PlaylistsRail"; import BackToTop from "./components/BackToTop"; -import PageScroller from "./components/PageScroller"; +import PageShell from "./components/PageShell"; import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import Toaster from "./components/Toaster"; @@ -715,34 +715,34 @@ export default function App() { /> ) : ( - <> -
{ - setChannelFilter("needs_full"); - setChannelsView("subscribed"); // the status filter applies to the subscriptions tab - setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows - setPage("channels"); - }} - /> - {/* The header floats (fixed) over the content, so it no longer occupies flow space; this - wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level - floating panels beside the nav — none sit inside this content column anymore.) */} -
- {meQuery.data!.is_demo && } - openReleaseNotes(CURRENT_VERSION)} /> - + { + setChannelFilter("needs_full"); + setChannelsView("subscribed"); // the status filter applies to the subscriptions tab + setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows + setPage("channels"); + }} + /> + } + banners={ + <> + {meQuery.data!.is_demo && } + openReleaseNotes(CURRENT_VERSION)} /> + + } + > {page === "channels" ? ( )} - -
- + )} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. Anchored inside the content column so they clear the sidebar automatically diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx new file mode 100644 index 00000000..f496617e --- /dev/null +++ b/frontend/src/components/PageShell.tsx @@ -0,0 +1,42 @@ +import { type ReactNode } from "react"; +import PageScroller from "./PageScroller"; + +// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what +// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page +// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate +// branch that rendered its OWN scroller) collapses to one contract and one scroller. +// +// Regions, top to bottom: +// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads +// its top by that height so nothing hides under it. Omit it (channel page) → no pad +// and no fixed top band, so the module's own banner can scroll from the very top. +// - `banners` — in-flow, fixed notices (demo/version) above the scroll region. +// - `toolbar` — in-flow, fixed per-module band (filter chips / sort / counts) that must NOT scroll +// with the content. +// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two +// invariants (`scrollbar-gutter: stable`, edge fade); see that file. +// +// The shell renders a fragment so it drops straight into App's `relative` content column — the +// floating header keeps resolving its `absolute` position against that same ancestor. +export default function PageShell({ + header, + banners, + toolbar, + children, +}: { + header?: ReactNode; + banners?: ReactNode; + toolbar?: ReactNode; + children: ReactNode; +}) { + return ( + <> + {header} +
+ {banners} + {toolbar} + {children} +
+ + ); +} From d81f10b1cfcbb997fc3c7e89e57c37ef0b880bcc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 03:19:34 +0200 Subject: [PATCH 02/16] refactor(shell): channel page is a PageShell config, not a 2nd scroller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel page rendered its own PageScroller as a separate App branch — the source of the app's two
s. Make it a headerless/rail-less PageShell config (its banner scrolls from the top) so there is exactly ONE page scroller. Verified: single
, banner scrolls, in-page and browser Back both return to the feed; E2E 11/11. --- frontend/src/App.tsx | 2 ++ frontend/src/components/ChannelPage.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6f1405f..be880ca0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -689,6 +689,7 @@ export default function App() { )}
{channelView ? ( + + ) : ( + <> {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( @@ -466,6 +467,6 @@ export default function ChannelPage({ )}
)} - + ); } From a6cbc4533439d1b93a8148f4e7cc3a06d211f05d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 03:25:48 +0200 Subject: [PATCH 03/16] fix(feed): pin the feed toolbar in the shell's fixed band (bug 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed's show/content chips, source, count, sort and view switcher — plus the active-filter chips — lived inside the scroller, so they scrolled away with the cards; only the SearchBar strip stayed fixed. Give PageShell a fixed toolbar band (filled by a module via the new PageToolbar portal) above the scroller. On the feed page the toolbar + chips portal into it and stay put — and removable — while cards scroll. The channel-embedded Feed (channelScoped) keeps its inline toolbar, which rides with the banner/tabs as before. Verified both entry points at desktop + 1000px (toolbar wraps in-flow, band grows, still pinned); E2E 11/11; tsc + knip clean. --- frontend/src/components/Feed.tsx | 30 +++++++++++++++++--------- frontend/src/components/PageShell.tsx | 31 ++++++++++++++++++++------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 45903f07..c47b5743 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -31,6 +31,7 @@ import { import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; +import { PageToolbar } from "./PageShell"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. @@ -654,17 +655,26 @@ export default function Feed({ ); return ( -
- {toolbar} - {/* Channel pages get no chips: they have no filter panel to reveal (App hides the sidebar for - them), and their Feed starts from its OWN baseline — channel-scoped, whole-catalog, - show-all — so every one of those would render as a "filter you applied" when it's just the +
+ {/* On the feed page the toolbar + active-filter chips are FIXED chrome: they pin in the + shell's toolbar band (above the scroller) so they stay put — and reachable — while the + cards scroll (bug 4). On a channel page the Feed is embedded below the channel's own + banner/tabs, which scroll, so there the toolbar rides inline with the content as before. + Channel pages get no chips: their Feed starts from its OWN baseline (channel-scoped, + whole-catalog, show-all), so each would read as a "filter you applied" when it's just the page being itself. */} - {!channelScoped && ( - setFilters(clearedFilters(filters))} - /> + {channelScoped ? ( + toolbar + ) : ( + +
+ {toolbar} + setFilters(clearedFilters(filters))} + /> +
+
)} {items.length === 0 ? (
diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index f496617e..a526cc67 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -1,6 +1,22 @@ -import { type ReactNode } from "react"; +import { createContext, useContext, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; +// The DOM node of the shell's fixed toolbar band, published so a module deep inside the scroller +// can render its own toolbar UP into the fixed region (see PageToolbar below). +const ToolbarSlot = createContext(null); + +// A module renders its per-module fixed band (filter chips / sort / counts) through this. The +// content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content +// scrolls and is never touched by the scroller's edge-fade mask. It's a portal, so the toolbar's +// event handlers and closures still belong to the module's React subtree (setFilters etc. work as +// if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and +// nothing when there's no shell — so a module works with or without one. +export function PageToolbar({ children }: { children: ReactNode }) { + const slot = useContext(ToolbarSlot); + return slot ? createPortal(children, slot) : null; +} + // The page shell: the fixed chrome around a single scrollable content region. It formalizes "what // is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page // (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate @@ -11,8 +27,8 @@ import PageScroller from "./PageScroller"; // its top by that height so nothing hides under it. Omit it (channel page) → no pad // and no fixed top band, so the module's own banner can scroll from the very top. // - `banners` — in-flow, fixed notices (demo/version) above the scroll region. -// - `toolbar` — in-flow, fixed per-module band (filter chips / sort / counts) that must NOT scroll -// with the content. +// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a +// module via . Empty ⇒ zero height, so pages without one lose nothing. // - children — the ONE scrollable region. `PageScroller` owns the scroller and its two // invariants (`scrollbar-gutter: stable`, edge fade); see that file. // @@ -21,22 +37,21 @@ import PageScroller from "./PageScroller"; export default function PageShell({ header, banners, - toolbar, children, }: { header?: ReactNode; banners?: ReactNode; - toolbar?: ReactNode; children: ReactNode; }) { + const [toolbarSlot, setToolbarSlot] = useState(null); return ( - <> + {header}
{banners} - {toolbar} +
{children}
- + ); } From 10f4baab3b16ac1b750c90a475fafd71663c013d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 03:51:40 +0200 Subject: [PATCH 04/16] fix(shell): scroll restore on Back + reset on new filter (bugs 1, 3) The shell owns the scroller, so it owns the scroll policy. PageScroller now takes a scrollKey identifying the logical view and keeps a per-view offset in a module-level map, recorded live on scroll: - same key returning (feed -> channel -> Back, filters unchanged) restores where you were (bug 1) -- the feed's scroller unmounts, but the saved offset survives and is re-applied, retried over a few frames so the virtualized list has time to reach its full height; - a new key (feed filters changed) starts at the top of the fresh result set (bug 3). q is normalised out of the feed key so typing doesn't reset. Recording on scroll rather than at unmount matters: by cleanup time the scroller's scrollTop is already zeroed, so a cleanup-read saved 0. Also fixes a Maximum-update-depth loop this introduced: the combined element/fade ref was an inline callback, so React re-attached it every commit and re-ran useScrollFade's state setter. Bound it with useCallback. Adds E2E locks for bugs 1, 3 and 4 (restore on Back, reset on filter, toolbar stays pinned). Full suite 14/14 green on :5173; tsc + knip clean. --- frontend/e2e/channel.spec.ts | 26 +++++++ frontend/e2e/feed.spec.ts | 31 ++++++++ frontend/src/App.tsx | 14 +++- frontend/src/components/PageScroller.tsx | 91 ++++++++++++++++++++++-- frontend/src/components/PageShell.tsx | 5 +- 5 files changed, 158 insertions(+), 9 deletions(-) diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 9a4313ab..74dd3d7e 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -29,4 +29,30 @@ test.describe("channel view", () => { await expect(page.getByTestId("channel-title")).toHaveCount(0); await expect(page.getByTestId("video-card").first()).toBeVisible(); }); + + // Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position + // rather than dumping you at the top. The shell owns the scroller and remembers each view's offset. + test("Back restores the feed scroll position", async ({ page }) => { + await openApp(page); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + + const main = page.getByRole("main"); + await main.evaluate((el) => el.scrollBy(0, 3000)); + // Let any page-fetch triggered by the scroll settle, so the same content is cached for the + // return trip (a fetch still in flight at navigation would leave a shorter list to restore into). + await page.waitForTimeout(600); + const saved = await main.evaluate((el) => el.scrollTop); + expect(saved).toBeGreaterThan(1500); + + await page.getByTestId("video-card-channel").first().click(); + await expect(page.getByTestId("channel-title")).toBeVisible(); + await page.getByTestId("channel-back").click(); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + + // Restored to roughly where we were, not reset to the top. Tolerance absorbs the virtualizer's + // estimate-vs-measured row heights differing slightly between the two mounts. + await expect + .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 }) + .toBeGreaterThan(saved - 500); + }); }); diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts index 95de5ce0..724652c2 100644 --- a/frontend/e2e/feed.spec.ts +++ b/frontend/e2e/feed.spec.ts @@ -47,4 +47,35 @@ test.describe("feed", () => { .poll(async () => (await renderedIds()).some((id) => id && !before.includes(id))) .toBe(true); }); + + // Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it + // pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll. + test("the feed toolbar stays pinned while cards scroll", async ({ page }) => { + await openApp(page); + const count = page.getByTestId("feed-result-count"); + await expect(count).toBeVisible(); + const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top); + + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000)); + + await expect(count).toBeInViewport(); + const topAfter = await count.evaluate((el) => el.getBoundingClientRect().top); + expect(Math.abs(topAfter - topBefore)).toBeLessThan(4); + }); + + // Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the + // top rather than stranding you deep in the old list's offset. + test("a new filter resets the scroll to the top", async ({ page }) => { + await openApp(page); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000)); + expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500); + + // Switch the show filter → a different result set → back to the top. + await page.getByTestId("feed-show-all").click(); + await expect + .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)) + .toBeLessThan(50); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be880ca0..8b6b7e01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -632,6 +632,17 @@ export default function App() {
{t("common.loading")}
); + // Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same + // page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so + // the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search + // box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key. + const normalScrollKey = + page !== "feed" + ? `page:${page}` + : ytSearch != null + ? `yt:${ytSearch}` + : `feed:${JSON.stringify({ ...filters, q: "" })}`; + return (
{channelView ? ( - + ) : ( (); + +// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame +// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise +// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now). +function restoreScroll(el: HTMLElement, top: number): () => void { + if (top <= 0) { + el.scrollTop = 0; + return () => {}; + } + let raf = 0; + let frames = 0; + let cancelled = false; + const tick = () => { + if (cancelled) return; + if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top; + if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; +} + +// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the +// channel page and Plex browse all scroll inside it rather than owning a scroller of their own, +// which is why the edge-fade and the scroll restore/reset policy live here once instead of three +// times. // // Two invariants this element carries, both learned the hard way: // @@ -19,13 +48,61 @@ import { useScrollFade } from "../lib/useScrollFade"; // the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the // fade next to its scrollbar, not instead of it. // +// Scroll restore/reset policy (`scrollKey` identifies the logical view): +// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged — +// restores where you were (bug 1). The channel page is a different scroller, so the feed's +// scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied. +// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3). +// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup +// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read +// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic +// unmount, which would otherwise persist a 0 too). +// // The fade state lives in here rather than in App so that a flip re-renders this component alone — // `children` arrives as an already-built element, so React skips the page subtree underneath. -export default function PageScroller({ children }: { children: ReactNode }) { +export default function PageScroller({ + children, + scrollKey, +}: { + children: ReactNode; + scrollKey?: string; +}) { const fade = useScrollFade(); + const fadeRef = fade.ref; + const elRef = useRef(null); + // Stable identity: an inline ref callback changes every render, which makes React detach and + // re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it + // into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding + // once here is safe. + const setRef = useCallback( + (node: HTMLElement | null) => { + elRef.current = node; + fadeRef(node); + }, + [fadeRef], + ); + + useLayoutEffect(() => { + const el = elRef.current; + if (!el || scrollKey == null) return; + const key = scrollKey; + const want = savedScroll.get(key) ?? 0; + const cancelRestore = restoreScroll(el, want); + // Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the + // time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last. + const onScroll = () => { + savedScroll.set(key, el.scrollTop); + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + cancelRestore(); + el.removeEventListener("scroll", onScroll); + }; + }, [scrollKey]); + return (
diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index a526cc67..1ff0b2f6 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -38,10 +38,13 @@ export default function PageShell({ header, banners, children, + scrollKey, }: { header?: ReactNode; banners?: ReactNode; children: ReactNode; + // Identifies the logical view for the scroll restore/reset policy — see PageScroller. + scrollKey?: string; }) { const [toolbarSlot, setToolbarSlot] = useState(null); return ( @@ -50,7 +53,7 @@ export default function PageShell({
{banners}
- {children} + {children}
); From 0e03e561eb2f547df84b2491d2aaad2e9b500954 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 04:45:11 +0200 Subject: [PATCH 05/16] feat(shell): sticky tabs+toolbar on the channel page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise the fixed-chrome pattern to the channel detail page: the banner + identity now scroll away, while the tab row and the channel feed's toolbar stick to the top as a sticky sub-header — only the video grid scrolls. Mechanism: PageToolbarSlot (the toolbar portal target) is now an exported, overridable context. ChannelPage provides its own sticky sub-header node as the target, so the channelScoped Feed portals its toolbar up next to the tabs instead of into the shell's top band. Feed always portals its toolbar now (the slot decides where it lands). Also: the page scroller now fades only its BOTTOM edge — the top is bounded by the fixed/sticky chrome, so a top fade only bit into it. E2E: adds a lock that the channel tabs/toolbar stay pinned while the banner scrolls away; suite 15/15 on :5173, tsc + knip clean. --- frontend/e2e/channel.spec.ts | 24 ++++++++++++++++++ frontend/src/components/ChannelPage.tsx | 17 +++++++++++-- frontend/src/components/Feed.tsx | 31 +++++++++++------------- frontend/src/components/PageScroller.tsx | 4 ++- frontend/src/components/PageShell.tsx | 14 ++++++----- frontend/src/lib/useScrollFade.ts | 15 +++++++++--- 6 files changed, 75 insertions(+), 30 deletions(-) diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 74dd3d7e..957d4cd9 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -55,4 +55,28 @@ test.describe("channel view", () => { .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 }) .toBeGreaterThan(saved - 500); }); + + // Platform S4b: the channel page's banner/identity scroll away, but the tabs + feed toolbar stick + // to the top (a sticky sub-header) so only the video grid scrolls. + test("tabs + toolbar stay pinned while the channel videos scroll", async ({ page }) => { + await openApp(page); + await page.getByTestId("video-card-channel").first().click(); + const count = page.getByTestId("feed-result-count"); + await expect(count).toBeVisible(); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + await expect(page.getByTestId("channel-title")).toBeInViewport(); + + // Keep nudging until the grid is tall enough to actually scroll (it may still be filling in). + await expect + .poll(async () => { + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500)); + return page.getByRole("main").evaluate((el) => el.scrollTop); + }) + .toBeGreaterThan(400); + + // Banner/identity scrolled away, but the tabs+toolbar stuck near the top. + await expect(page.getByTestId("channel-title")).not.toBeInViewport(); + await expect(count).toBeInViewport(); + expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBeLessThan(130); + }); }); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index a4e11041..c3cefd8c 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import Avatar from "./Avatar"; import Feed from "./Feed"; +import { PageToolbarSlot } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -76,6 +77,10 @@ export default function ChannelPage({ const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); + // The sticky sub-header hosts the tabs AND the channel feed's toolbar: the Feed portals its + // toolbar into this node (via the overridden PageToolbarSlot below) so both ride together and + // stay put while the banner/identity scroll away above them. + const [chromeSlot, setChromeSlot] = useState(null); // The banner URL is valid, but googleusercontent intermittently throttles it when the channel // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would @@ -294,11 +299,16 @@ export default function ChannelPage({ {t("channel.loadingVideos")}
)} +
+ {/* Sticky sub-header: the tabs and (on the Videos tab) the feed toolbar ride here and stay put + while the banner + identity scroll away above them. The toolbar portals into `chromeSlot` + below via the overridden PageToolbarSlot; only the video grid scrolls. */} +
{/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} -
+
@@ -365,9 +375,11 @@ export default function ChannelPage({ ))}
+
- {/* Tab content */} + {/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */} + {tab === "videos" ? ( <> )} + ); } diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index c47b5743..8997516b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -655,27 +655,24 @@ export default function Feed({ ); return ( -
- {/* On the feed page the toolbar + active-filter chips are FIXED chrome: they pin in the - shell's toolbar band (above the scroller) so they stay put — and reachable — while the - cards scroll (bug 4). On a channel page the Feed is embedded below the channel's own - banner/tabs, which scroll, so there the toolbar rides inline with the content as before. - Channel pages get no chips: their Feed starts from its OWN baseline (channel-scoped, - whole-catalog, show-all), so each would read as a "filter you applied" when it's just the - page being itself. */} - {channelScoped ? ( - toolbar - ) : ( - -
- {toolbar} +
+ {/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so + it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top + band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so + the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN + baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you + applied" when it's just the page being itself. */} + +
+ {toolbar} + {!channelScoped && ( setFilters(clearedFilters(filters))} /> -
-
- )} + )} +
+ {items.length === 0 ? (

{t("feed.noMatches")}

diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx index 25d34edb..0118d1de 100644 --- a/frontend/src/components/PageScroller.tsx +++ b/frontend/src/components/PageScroller.tsx @@ -67,7 +67,9 @@ export default function PageScroller({ children: ReactNode; scrollKey?: string; }) { - const fade = useScrollFade(); + // Only the bottom edge fades: the top is bounded by the shell's fixed chrome (header / toolbar + // band) or a module's sticky sub-header, so a top fade would just eat into that chrome. + const fade = useScrollFade(20, { top: false }); const fadeRef = fade.ref; const elRef = useRef(null); // Stable identity: an inline ref callback changes every render, which makes React detach and diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index 1ff0b2f6..3833b277 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -2,9 +2,11 @@ import { createContext, useContext, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; -// The DOM node of the shell's fixed toolbar band, published so a module deep inside the scroller -// can render its own toolbar UP into the fixed region (see PageToolbar below). -const ToolbarSlot = createContext(null); +// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its +// fixed band as the default target; a module can override it with its own +// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the +// toolbar rides with the tabs instead of the shell's top band. +export const PageToolbarSlot = createContext(null); // A module renders its per-module fixed band (filter chips / sort / counts) through this. The // content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content @@ -13,7 +15,7 @@ const ToolbarSlot = createContext(null); // if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and // nothing when there's no shell — so a module works with or without one. export function PageToolbar({ children }: { children: ReactNode }) { - const slot = useContext(ToolbarSlot); + const slot = useContext(PageToolbarSlot); return slot ? createPortal(children, slot) : null; } @@ -48,13 +50,13 @@ export default function PageShell({ }) { const [toolbarSlot, setToolbarSlot] = useState(null); return ( - + {header}
{banners}
{children}
- + ); } diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index 642e7877..dfdd9958 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -7,10 +7,17 @@ import { useEffect, useState, type CSSProperties } from "react"; // to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback // ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that // mounts collapsed and only renders its scroll body once expanded still wires up correctly. -export function useScrollFade(fadePx = 20): { +// `edges` gates which ends fade. The page scroller passes `{ top: false }`: its top is delimited by +// the fixed/sticky module chrome (header, toolbar band, a sticky sub-header), so a top fade there +// would only bite into that chrome instead of hinting at more content. +export function useScrollFade( + fadePx = 20, + edges: { top?: boolean; bottom?: boolean } = {}, +): { ref: (node: HTMLElement | null) => void; style: CSSProperties; } { + const { top: fadeTop = true, bottom: fadeBottom = true } = edges; const [el, setEl] = useState(null); const [fade, setFade] = useState({ up: false, down: false }); @@ -53,8 +60,8 @@ export function useScrollFade(fadePx = 20): { }; }, [el]); - const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ - fade.down ? fadePx : 0 - }px), transparent)`; + const topPx = fadeTop && fade.up ? fadePx : 0; + const bottomPx = fadeBottom && fade.down ? fadePx : 0; + const mask = `linear-gradient(to bottom, transparent, #000 ${topPx}px, #000 calc(100% - ${bottomPx}px), transparent)`; return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; } From ec807c8b31463156b4f28c0dc51a673d8230e507 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 04:53:49 +0200 Subject: [PATCH 06/16] feat(shell): channel chrome is fully fixed, only the grid scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the channel detail per UAT: instead of the banner scrolling away and a sticky sub-header (which felt odd to scroll and needed an opaque bar that clashed with the design even at rest), the ENTIRE chrome — banner, identity, tabs, toolbar — is fixed and only the video grid scrolls. Mechanism: ChannelPage wraps its whole chrome in so it portals into the shell's fixed band (like the feed toolbar); the channelScoped Feed portals its toolbar into a slot at the bottom of that chrome. No sticky, no opaque bar — the chrome is transparent over the page bg because nothing scrolls under it (the band sits above the scroller). E2E updated: the channel chrome (title + toolbar) stays at a fixed viewport position while the grid scrolls. Suite 15/15, tsc clean. --- frontend/e2e/channel.spec.ts | 17 +++++++------- frontend/src/components/ChannelPage.tsx | 30 +++++++++++++------------ frontend/src/components/Feed.tsx | 2 +- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 957d4cd9..ae8b3c1a 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -56,15 +56,17 @@ test.describe("channel view", () => { .toBeGreaterThan(saved - 500); }); - // Platform S4b: the channel page's banner/identity scroll away, but the tabs + feed toolbar stick - // to the top (a sticky sub-header) so only the video grid scrolls. - test("tabs + toolbar stay pinned while the channel videos scroll", async ({ page }) => { + // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays + // put while only the video grid scrolls. + test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => { await openApp(page); await page.getByTestId("video-card-channel").first().click(); const count = page.getByTestId("feed-result-count"); + const title = page.getByTestId("channel-title"); await expect(count).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible(); - await expect(page.getByTestId("channel-title")).toBeInViewport(); + const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top); + const countTop = await count.evaluate((el) => el.getBoundingClientRect().top); // Keep nudging until the grid is tall enough to actually scroll (it may still be filling in). await expect @@ -74,9 +76,8 @@ test.describe("channel view", () => { }) .toBeGreaterThan(400); - // Banner/identity scrolled away, but the tabs+toolbar stuck near the top. - await expect(page.getByTestId("channel-title")).not.toBeInViewport(); - await expect(count).toBeInViewport(); - expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBeLessThan(130); + // The chrome didn't move — banner title and toolbar are in exactly the same place. + expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop); + expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop); }); }); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index c3cefd8c..74c26280 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -5,7 +5,7 @@ import { useNavigationActions } from "./NavigationProvider"; import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react"; import Avatar from "./Avatar"; import Feed from "./Feed"; -import { PageToolbarSlot } from "./PageShell"; +import { PageToolbar, PageToolbarSlot } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -77,9 +77,10 @@ export default function ChannelPage({ const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); - // The sticky sub-header hosts the tabs AND the channel feed's toolbar: the Feed portals its - // toolbar into this node (via the overridden PageToolbarSlot below) so both ride together and - // stay put while the banner/identity scroll away above them. + // The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's + // fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is + // where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so + // the toolbar lands under the tabs inside the fixed band rather than in the scroller. const [chromeSlot, setChromeSlot] = useState(null); // The banner URL is valid, but googleusercontent intermittently throttles it when the channel // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the @@ -210,10 +211,12 @@ export default function ChannelPage({ ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; - // The channel page owns no scroller of its own: it's a PageShell config (no shared header, no - // rail, its banner scrolls from the top) so there's ONE page scroller in the app, not two. + // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its + // whole chrome is fixed — wrapped in it portals into the shell's fixed band, so the + // banner/identity/tabs/toolbar stay put and only the video grid scrolls. return ( <> + {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( @@ -299,16 +302,11 @@ export default function ChannelPage({ {t("channel.loadingVideos")}
)} -
- {/* Sticky sub-header: the tabs and (on the Videos tab) the feed toolbar ride here and stay put - while the banner + identity scroll away above them. The toolbar portals into `chromeSlot` - below via the overridden PageToolbarSlot; only the video grid scrolls. */} -
{/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} -
+
@@ -375,10 +373,14 @@ export default function ChannelPage({ ))}
-
+ {/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome, + just under the tabs. */} +
+ - {/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */} + {/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override; + only the grid (and About) render here, in the scroller. */} {tab === "videos" ? ( <> diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 8997516b..65b354a8 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -655,7 +655,7 @@ export default function Feed({ ); return ( -
+
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so From 02bd0d433b6638544b8fda189caf73fc90d45856 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 05:01:31 +0200 Subject: [PATCH 07/16] polish(channel): tighten toolbar-to-grid gap + restore top scroll fade Two UAT nits on the channel page: - the chip-row-to-cards gap was ~36px (toolbar pb-3 + wrapper pb-2 + grid pt-3 stacking); drop the wrapper/grid pads so the toolbar's own pb-3 is the only gap (~15px). - restore the page scroller's TOP edge fade. It was dropped when the chrome was a sticky sub-header (the fade bit into it); now the chrome is a fixed band ABOVE the scroller, so the top fade correctly hints at cards scrolling up under the toolbar. Reverts the useScrollFade edges option. Suite 15/15, tsc + knip clean. --- frontend/src/components/Feed.tsx | 4 ++-- frontend/src/components/PageScroller.tsx | 4 +--- frontend/src/lib/useScrollFade.ts | 15 ++++----------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 65b354a8..da292476 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -655,7 +655,7 @@ export default function Feed({ ); return ( -
+
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so @@ -663,7 +663,7 @@ export default function Feed({ baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you applied" when it's just the page being itself. */} -
+
{toolbar} {!channelScoped && ( (null); // Stable identity: an inline ref callback changes every render, which makes React detach and diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index dfdd9958..642e7877 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -7,17 +7,10 @@ import { useEffect, useState, type CSSProperties } from "react"; // to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback // ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that // mounts collapsed and only renders its scroll body once expanded still wires up correctly. -// `edges` gates which ends fade. The page scroller passes `{ top: false }`: its top is delimited by -// the fixed/sticky module chrome (header, toolbar band, a sticky sub-header), so a top fade there -// would only bite into that chrome instead of hinting at more content. -export function useScrollFade( - fadePx = 20, - edges: { top?: boolean; bottom?: boolean } = {}, -): { +export function useScrollFade(fadePx = 20): { ref: (node: HTMLElement | null) => void; style: CSSProperties; } { - const { top: fadeTop = true, bottom: fadeBottom = true } = edges; const [el, setEl] = useState(null); const [fade, setFade] = useState({ up: false, down: false }); @@ -60,8 +53,8 @@ export function useScrollFade( }; }, [el]); - const topPx = fadeTop && fade.up ? fadePx : 0; - const bottomPx = fadeBottom && fade.down ? fadePx : 0; - const mask = `linear-gradient(to bottom, transparent, #000 ${topPx}px, #000 calc(100% - ${bottomPx}px), transparent)`; + const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ + fade.down ? fadePx : 0 + }px), transparent)`; return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; } From 7df9bc00357e5e249268e0ecf2d958eaf5b28e13 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 05:11:10 +0200 Subject: [PATCH 08/16] feat(datatable): sticky table header row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the DataTable's sticky so the column headers stay put at the top of the page scroller while the rows scroll under them — the core of 'the table header should be fixed, only the rows scroll'. Benefits every DataTable page (Channels manager, discovery, Audit log). Each header cell gets bg-bg so rows don't show through; the header's bottom border rides an inset box-shadow because a sticky 's own border can scroll away with the collapsed table box. Verified on the Audit log: 500 rows scroll under a header stuck at the scroller top; the scroller's top fade is unobtrusive behind the solid header. Suite 15/15. --- frontend/src/components/DataTable.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index f08bb64e..f247260e 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -350,8 +350,12 @@ export default function DataTable({ {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
- - + {/* Sticky header row: the column headers stay put at the top of the page scroller while + the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the + border rides a pseudo-element (box-shadow) since a sticky 's own border scrolls + away with the collapsed table box in some browsers. */} + + {columns.map((col) => { const sorted_ = sort?.key === col.key; const filterOn = isActive(filters[col.key]); @@ -359,7 +363,7 @@ export default function DataTable({
- {tagManagerOpen && ( - setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> - )} + {/* Sync-status chips — the last band of fixed chrome, right above the table header. */} + {statusChips} + + + {tagManagerOpen && ( + setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> + )} - {/* Channel table */} +
+ {/* Channel table — only the rows scroll; the header row is sticky (see DataTable). */} {channelsQuery.isLoading ? (
{t("channels.loading")}
) : ( @@ -549,7 +559,6 @@ export default function Channels({ rowKey={(c) => c.id} persistKey={accountKey(LS.channelsTable) ?? undefined} controlsPosition="top" - controlsLeading={statusChips} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> From 867daa0dcffb55c2624e08720cd5501c8062c099 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 05:34:07 +0200 Subject: [PATCH 10/16] feat(channels): pager into the chip row, Blocked as its own tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address manager UAT: - The DataTable controls row (status chips + pager) now portals into the fixed band via a new controlsInBand prop, so it stays put above the sticky header instead of scrolling away — and the pager fills the empty space next to the status chips, on one row. - The tab bar is always fixed chrome now (not just on Subscriptions), so it doesn't jump when switching tabs; on Subscriptions the stats/intro/ tags ride with it. - Blocked channels moves off the bottom of the Subscriptions list into its own 'Blocked' tab (with an empty state), alongside Subscriptions and Discover. Verified with a 90-channel test account: everything above the rows stays fixed while the rows scroll under the sticky header. Suite 15/15, tsc + knip clean. --- frontend/src/App.tsx | 6 +- frontend/src/components/Channels.tsx | 137 +++++++++++---------- frontend/src/components/DataTable.tsx | 19 ++- frontend/src/i18n/locales/en/channels.json | 6 +- frontend/src/i18n/locales/hu/channels.json | 6 +- 5 files changed, 101 insertions(+), 73 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b6b7e01..f4701603 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -204,7 +204,11 @@ export default function App() { // of dumping the user on whatever tab they last left open. const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed"); const channelsView: ChannelsView = - channelsViewRaw === "discovery" ? "discovery" : "subscribed"; + channelsViewRaw === "discovery" + ? "discovery" + : channelsViewRaw === "blocked" + ? "blocked" + : "subscribed"; // Plex module filters (its own left-sidebar filter section) — per-account persisted. // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 2ce7c8c2..e1b2b586 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -31,7 +31,7 @@ import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; -export type ChannelsView = "subscribed" | "discovery"; +export type ChannelsView = "subscribed" | "discovery" | "blocked"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -383,7 +383,7 @@ export default function Channels({ const tabs = (
- {(["subscribed", "discovery"] as ChannelsView[]).map((v) => ( + {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
); - if (view === "discovery") { - return ( - <> - {tabs} - - - ); - } - return ( <> - {/* The whole manager header — tabs, stats, description, tags, status chips — is fixed chrome: - it portals into the shell's fixed band, so only the table rows scroll (under the sticky - header row). */} + {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never + scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride + with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */} {tabs} + {view === "subscribed" && (
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering now lives in the table headers). */} @@ -540,61 +527,77 @@ export default function Channels({ {t("channels.tags.manage")}
- {/* Sync-status chips — the last band of fixed chrome, right above the table header. */} - {statusChips}
+ )} {tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} -
- {/* Channel table — only the rows scroll; the header row is sticky (see DataTable). */} - {channelsQuery.isLoading ? ( -
{t("channels.loading")}
- ) : ( - c.id} - persistKey={accountKey(LS.channelsTable) ?? undefined} - controlsPosition="top" - rowClassName={(c) => (c.hidden ? "opacity-60" : "")} - emptyText={t("channels.empty")} - /> - )} - - {/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */} - {(blockedQuery.data?.length ?? 0) > 0 && ( -
-
- - - {t("channels.blocked.title")} - -
-
- {blockedQuery.data!.map((c) => ( - - {c.title ?? c.id} - + {view === "discovery" ? ( + + ) : view === "blocked" ? ( + /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ +
+ {(blockedQuery.data?.length ?? 0) === 0 ? ( +

{t("channels.blocked.empty")}

+ ) : ( + <> +
+ + + {t("channels.blocked.title")} - ))} -
-

{t("channels.blocked.hint")}

-
- )} -
+
+
+ {blockedQuery.data!.map((c) => ( + + {c.title ?? c.id} + + + ))} +
+

{t("channels.blocked.hint")}

+ + )} +
+ ) : ( +
+ {/* Channel table — only the rows scroll; the header row is sticky, and the controls row + (status chips + pager) rides in the fixed band right above it (controlsInBand). */} + {channelsQuery.isLoading ? ( +
{t("channels.loading")}
+ ) : ( + c.id} + persistKey={accountKey(LS.channelsTable) ?? undefined} + controlsPosition="top" + controlsLeading={statusChips} + controlsInBand + controlsBandClassName="px-4 max-w-7xl mx-auto" + rowClassName={(c) => (c.hidden ? "opacity-60" : "")} + emptyText={t("channels.empty")} + /> + )} +
+ )} ); } diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index f247260e..293c784a 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; +import { PageToolbar } from "./PageShell"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -84,6 +85,8 @@ export default function DataTable({ rowClassName, controlsPosition = "bottom", controlsLeading, + controlsInBand = false, + controlsBandClassName, externalFilter, resetFiltersToken, }: { @@ -97,6 +100,11 @@ export default function DataTable({ rowClassName?: (row: T) => string; controlsPosition?: "top" | "bottom" | "both"; controlsLeading?: ReactNode; + // Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead + // of inline, so it stays put above the sticky header while the rows scroll. `controlsBandClassName` + // aligns it with the page's fixed chrome (e.g. the same max-width wrapper). + controlsInBand?: boolean; + controlsBandClassName?: string; // Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied // whenever its value changes; the user can still clear it from the header afterwards. externalFilter?: { key: string; value: string } | null; @@ -344,9 +352,18 @@ export default function DataTable({
) : null; + const topControls = + controlsInBand && controls ? ( + +
{controls}
+
+ ) : ( + controls + ); + return (
- {(controlsPosition === "top" || controlsPosition === "both") && controls} + {(controlsPosition === "top" || controlsPosition === "both") && topControls} {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index faf4ea02..7e474b90 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -6,7 +6,8 @@ "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.", "tabs": { "subscribed": "Subscriptions", - "discovery": "Discover from playlists" + "discovery": "Discover from playlists", + "blocked": "Blocked" }, "discovery": { "intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).", @@ -119,6 +120,7 @@ "searchPlaceholder": "Search channels…", "blocked": { "title": "Blocked channels", - "hint": "Their videos are hidden from your feed, Library, and live search." + "hint": "Their videos are hidden from your feed, Library, and live search.", + "empty": "No blocked channels. Block one from its channel page to hide all its videos without unsubscribing." } } diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index f5333971..c0b4f68f 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -6,7 +6,8 @@ "backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.", "tabs": { "subscribed": "Feliratkozások", - "discovery": "Felfedezés a lejátszási listákból" + "discovery": "Felfedezés a lejátszási listákból", + "blocked": "Letiltott" }, "discovery": { "intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).", @@ -119,6 +120,7 @@ "searchPlaceholder": "Csatornák keresése…", "blocked": { "title": "Tiltott csatornák", - "hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből." + "hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből.", + "empty": "Nincs tiltott csatorna. Egy csatorna oldaláról tilthatsz le egyet, hogy minden videóját elrejtsd leiratkozás nélkül." } } From af915883f9b2234ddc1f34265764e801316a586a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 05:43:19 +0200 Subject: [PATCH 11/16] fix(datatable): the sticky header no longer shifts or fades on first scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sticky jumped up ~8px and picked up the scroller's top fade the moment you scrolled 1px — so it read as 'not fixed'. Two causes: - the table sat under a pt-2 gap, so its natural position was 8px below the sticky top:0 it snapped to. Drop the top padding (the fixed band's controls row already gives the visual gap) → no shift. - the page scroller's top edge fade turned on with the first scroll and masked the header's top. Add a fadeTop flag (PageShell → PageScroller → useScrollFade), off for the Channels + Audit pages whose scroll region starts with a sticky header. Feed/channel keep the top fade (it fades cards under the fixed toolbar, as intended). Verified: header stays at a fixed offset with no fade change from scroll 0 through scrolled, on both the manager and the audit log. Suite 15/15. --- frontend/src/App.tsx | 3 +++ frontend/src/components/Channels.tsx | 5 ++++- frontend/src/components/PageScroller.tsx | 5 ++++- frontend/src/components/PageShell.tsx | 7 ++++++- frontend/src/lib/useScrollFade.ts | 7 +++++-- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4701603..3767d49d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -734,6 +734,9 @@ export default function App() { ) : ( ) : ( -
+ /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when + `position: sticky` engages on the first scroll (the band's controls row above gives the + visual gap). */ +
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row (status chips + pager) rides in the fixed band right above it (controlsInBand). */} {channelsQuery.isLoading ? ( diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx index 25d34edb..7c64ebc9 100644 --- a/frontend/src/components/PageScroller.tsx +++ b/frontend/src/components/PageScroller.tsx @@ -63,11 +63,14 @@ function restoreScroll(el: HTMLElement, top: number): () => void { export default function PageScroller({ children, scrollKey, + fadeTop = true, }: { children: ReactNode; scrollKey?: string; + // Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade. + fadeTop?: boolean; }) { - const fade = useScrollFade(); + const fade = useScrollFade(20, fadeTop); const fadeRef = fade.ref; const elRef = useRef(null); // Stable identity: an inline ref callback changes every render, which makes React detach and diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index 3833b277..beb31979 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -41,12 +41,15 @@ export default function PageShell({ banners, children, scrollKey, + fadeTop = true, }: { header?: ReactNode; banners?: ReactNode; children: ReactNode; // Identifies the logical view for the scroll restore/reset policy — see PageScroller. scrollKey?: string; + // Off when this page's scroll region starts with a sticky header — see PageScroller. + fadeTop?: boolean; }) { const [toolbarSlot, setToolbarSlot] = useState(null); return ( @@ -55,7 +58,9 @@ export default function PageShell({
{banners}
- {children} + + {children} +
); diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index 642e7877..f1474830 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -7,7 +7,10 @@ import { useEffect, useState, type CSSProperties } from "react"; // to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback // ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that // mounts collapsed and only renders its scroll body once expanded still wires up correctly. -export function useScrollFade(fadePx = 20): { +// `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a +// table's sticky header): fading there would eat into the header instead of hinting at content, and +// the header would visibly change as the fade turns on with the first scroll. +export function useScrollFade(fadePx = 20, fadeTop = true): { ref: (node: HTMLElement | null) => void; style: CSSProperties; } { @@ -53,7 +56,7 @@ export function useScrollFade(fadePx = 20): { }; }, [el]); - const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${ + const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up && fadeTop ? fadePx : 0}px, #000 calc(100% - ${ fade.down ? fadePx : 0 }px), transparent)`; return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; From e9135a018318873f3909966c77f4afa6e0c9b036 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 05:51:11 +0200 Subject: [PATCH 12/16] polish(datatable): fade rows under the sticky header, not the header itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rows now dissolve as they slide up under the sticky header (matching the bottom fade), while the header stays crisp. A short gradient painted by the header's ::after sits just below it and fades the rows — this replaces the scroller's top mask-fade, which is off on these pages so it can't touch the header. Barely visible at rest (it covers the first row's top padding), clear once scrolling. Suite 15/15, tsc + knip clean. --- frontend/src/components/DataTable.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 293c784a..79cfcc95 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -370,8 +370,10 @@ export default function DataTable({ {/* Sticky header row: the column headers stay put at the top of the page scroller while the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the border rides a pseudo-element (box-shadow) since a sticky
's own border scrolls - away with the collapsed table box in some browsers. */} - + away with the collapsed table box in some browsers. The `::after` paints a short + fade just BELOW the header so rows dissolve as they slide under it (the header itself + stays crisp — the page scroller's own top fade is off on these pages). */} + {columns.map((col) => { const sorted_ = sort?.key === col.key; From 0c3ca7254549cabc08e602df8d3f58ec569b4bdf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 06:03:32 +0200 Subject: [PATCH 13/16] feat(playlists): fix the playlist header, scroll only the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the playlist detail header — cover/title/actions (Play all, sync, delete) and the sort/group controls row — into the shell's fixed band via PageToolbar, so only the video list scrolls under it. Matches the feed/channel/manager pattern. Verified with a 40-item playlist: the header holds at a fixed offset while the list scrolls. Suite 15/15, tsc + knip. --- frontend/src/components/Playlists.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index bb48586e..881458b7 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -38,6 +38,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { useUndoable } from "../lib/useUndoable"; const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; +import { PageToolbar } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; type SortKey = "manual" | "title" | "duration" | "channel"; @@ -443,13 +444,17 @@ export default function Playlists({ } return ( -
+
{selectedId == null || !detail ? (
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
) : ( <> + {/* Header (title, actions, sort/group) is fixed chrome — it portals into the shell's + fixed band, so only the video list scrolls. */} + +
{items[0]?.thumbnail_url && ( @@ -624,6 +629,8 @@ export default function Playlists({
)} +
+
{items.length === 0 ? (
From ed3990a9af3e5d9ffb579331865393bbf6f9c189 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 06:07:29 +0200 Subject: [PATCH 14/16] feat(plex): fix the title-count row, scroll only the poster grid Lift Plex browse's title-count line into the shell's fixed band via PageToolbar, so the count stays visible while only the poster grid scrolls (the filters already live in the left rail). Verified with a 3.5k-title library: the count holds at a fixed offset while the grid scrolls. Suite 15/15, tsc + knip clean. --- frontend/src/components/PlexBrowse.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 000d8a5b..06cda9db 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -33,6 +33,7 @@ import { useHistorySubview } from "../lib/history"; import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; +import { PageToolbar } from "./PageShell"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -286,9 +287,15 @@ export default function PlexBrowse({ return (
-

+ {/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put + while only the poster grid scrolls. */} + +

+

{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })} -

+

+
+ {browseQ.isLoading ? (

{t("plex.loading")}

From 685c7b4c0b73f8cf0d58aba24bee079d47b24bbd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 06:16:16 +0200 Subject: [PATCH 15/16] feat(shell): fix headers on Notifications, Downloads, Discovery Apply the fixed-chrome pattern to three more list modules: - Notifications: the inbox header (title + mark-all / clear-all) pins in the band; only the notification list scrolls. - Downloads: the title + subtitle + tabs pin; the tab content scrolls. - Channel discovery: the intro + table controls pin (controlsInBand), the table rows scroll under the sticky header (fills out the manager's Discover tab to match Subscriptions). Verified: headers land in the fixed band, content in the scroller. Suite 15/15, tsc + knip clean. --- frontend/src/components/ChannelDiscovery.tsx | 42 +++++++++++-------- frontend/src/components/DownloadCenter.tsx | 10 ++++- .../src/components/NotificationsPanel.tsx | 14 +++++-- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 0eb5c31d..6539652d 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import Tooltip from "./Tooltip"; import ChannelLink from "./ChannelLink"; import DataTable, { type Column } from "./DataTable"; +import { PageToolbar } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; // The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but @@ -154,22 +155,29 @@ export default function ChannelDiscovery({ ]; return ( -
-

- {t("channels.discovery.intro")} -

- {query.isLoading ? ( -
{t("channels.discovery.loading")}
- ) : ( - c.id} - persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} - controlsPosition="top" - emptyText={t("channels.discovery.empty")} - /> - )} -
+ <> + {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */} + +
+

{t("channels.discovery.intro")}

+
+
+
+ {query.isLoading ? ( +
{t("channels.discovery.loading")}
+ ) : ( + c.id} + persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} + controlsPosition="top" + controlsInBand + controlsBandClassName="px-4 max-w-7xl mx-auto" + emptyText={t("channels.discovery.empty")} + /> + )} +
+ ); } diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index ebdbab5f..d788620f 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react"; import clsx from "clsx"; import Tabs, { usePersistedTab } from "./Tabs"; +import { PageToolbar } from "./PageShell"; import Modal from "./Modal"; // Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until // used (the video editor in particular is heavy — filmstrip + scrubber). @@ -600,7 +601,10 @@ export default function DownloadCenter({ me }: { me: Me }) { ); return ( -
+ <> + {/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */} + +

{t("downloads.page.title")}

+ +
{tab === "queue" && ( <>
@@ -739,5 +746,6 @@ export default function DownloadCenter({ me }: { me: Me }) { {editJob && setEditJob(null)} />}
+ ); } diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index b13f32dd..aed27a59 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -21,6 +21,7 @@ import { } from "../lib/notifications"; import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { LEVEL_STYLE } from "./Toaster"; +import { PageToolbar } from "./PageShell"; import { useNavigationActions } from "./NavigationProvider"; // The single notification center. "System" = durable, server-backed notifications (inbox @@ -102,8 +103,11 @@ export default function NotificationsPanel({ } return ( -
-
+ <> + {/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */} + +
+
{t("inbox.title")}
@@ -130,7 +134,10 @@ export default function NotificationsPanel({
)}
+
+
+
{q.isLoading && !q.data && clientItems.length === 0 ? (
{t("common.loading")}
) : !hasAny ? ( @@ -185,7 +192,8 @@ export default function NotificationsPanel({ )}
)} -
+
+ ); } From a2e371a55837761e3cff17c0f36722bd2b6b8d28 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 06:21:49 +0200 Subject: [PATCH 16/16] feat(admin): fix the tab bar on Users + Configuration pages Apply the fixed-chrome pattern to the two admin pages with a horizontal tab bar: the tabs (and Config's intro) pin in the shell's fixed band while the tab content scrolls under them. Config's floating save bar is unaffected. Suite 15/15, tsc + knip clean. --- frontend/src/components/AdminUsers.tsx | 20 ++++++++++++++------ frontend/src/components/ConfigPanel.tsx | 16 +++++++++++----- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 9c56ba28..e2fca998 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -9,6 +9,7 @@ import Tooltip from "./Tooltip"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; +import { PageToolbar } from "./PageShell"; // 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 @@ -26,12 +27,19 @@ export default function AdminUsers({ me }: { me: Me }) { ]; const active = tabs.some((x) => x.id === tab) ? tab : "roles"; return ( -
- - {active === "roles" && } - {active === "access" && } - {active === "demo" && } -
+ <> + {/* The tab bar is fixed chrome; the tab content scrolls under it. */} + +
+ +
+
+
+ {active === "roles" && } + {active === "access" && } + {active === "demo" && } +
+ ); } diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 6f803948..b06419f5 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -6,6 +6,7 @@ import { api, type ConfigItem, type PlexTestResult } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Tabs, { usePersistedTab } from "./Tabs"; +import { PageToolbar } from "./PageShell"; import { Switch } from "./ui/form"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { LS } from "../lib/storage"; @@ -151,11 +152,15 @@ export default function ConfigPanel() { })); return ( -
-

{t("config.intro")}

- - - + <> + {/* Intro + tab bar are fixed chrome; the config fields scroll under them. */} + +
+

{t("config.intro")}

+ +
+
+
{activeGroup && (
@@ -264,6 +269,7 @@ export default function ConfigPanel() { }} />
+ ); }