diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 9a4313ab..ae8b3c1a 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -29,4 +29,55 @@ 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); + }); + + // 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(); + 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 + .poll(async () => { + await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500)); + return page.getByRole("main").evaluate((el) => el.scrollTop); + }) + .toBeGreaterThan(400); + + // 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/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 4c649dc4..3767d49d 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"; @@ -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".) @@ -632,6 +636,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 ? ( + + ) : ( - <> -
{ - 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/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/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/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 4c76763e..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 PageScroller from "./PageScroller"; +import { PageToolbar, PageToolbarSlot } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -77,6 +77,11 @@ export default function ChannelPage({ const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); + // 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 // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would @@ -206,8 +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 (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 ? ( @@ -365,8 +374,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 */} + {/* 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/Channels.tsx b/frontend/src/components/Channels.tsx index 89ea1d68..f2ecd7c6 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -25,12 +25,13 @@ import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; +import { PageToolbar } from "./PageShell"; import ChannelLink from "./ChannelLink"; 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"; @@ -382,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 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). */}
@@ -535,57 +527,80 @@ export default function Channels({ {t("channels.tags.manage")}
- {tagManagerOpen && ( - setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> - )} - - {/* Channel table */} - {channelsQuery.isLoading ? ( -
{t("channels.loading")}
- ) : ( - c.id} - persistKey={accountKey(LS.channelsTable) ?? undefined} - controlsPosition="top" - controlsLeading={statusChips} - 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} - - - ))} -
-

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

-
- )}
+ )} + + {tagManagerOpen && ( + setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> + )} + + {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")} + +
+
+ {blockedQuery.data!.map((c) => ( + + {c.title ?? c.id} + + + ))} +
+

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

+ + )} +
+ ) : ( + /* 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 ? ( +
{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/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() { }} />
+ ); } diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index f08bb64e..79cfcc95 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,14 +352,29 @@ 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. */}
- - + {/* 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. 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; const filterOn = isActive(filters[col.key]); @@ -359,7 +382,7 @@ export default function DataTable({