Merge Platform S4 + S4b: PageShell contract + fixed-chrome sweep

S4: PageShell/PageToolbar contract, one page scroller, scroll restore on
Back + reset on new filter (bugs 1,3), pinned feed toolbar (bug 4), and
the fix for the ref-churn 'max update depth' loop.

S4b: the fixed-header / scrollable-content pattern applied across nine
modules (feed, channel detail, channel manager + discovery, playlist,
plex, notifications, downloads, admin users/config), plus the sticky
DataTable header with the header+fade 3D-depth edge the user liked.

E2E 17 specs green (incl. bug 1/3/4 locks); tsc + knip clean. Not shipped.
This commit is contained in:
2026-07-18 06:40:36 +02:00
19 changed files with 537 additions and 169 deletions
+51
View File
@@ -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);
});
});
+31
View File
@@ -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);
});
});
+52 -33
View File
@@ -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() {
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
);
// 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 (
<div className="h-screen flex text-fg">
<NavSidebar
@@ -689,6 +704,7 @@ export default function App() {
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
@@ -714,35 +730,40 @@ export default function App() {
}}
/>
</Suspense>
</PageShell>
) : (
<>
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
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.) */}
<div
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<PageScroller>
<PageShell
scrollKey={normalScrollKey}
// Channels + Audit render a table with a sticky header at the scroller top; the top
// fade would sit on it and flip on with the first scroll, so turn it off there.
fadeTop={page !== "channels" && page !== "audit"}
header={
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
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 && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
</>
}
>
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
@@ -823,9 +844,7 @@ export default function App() {
/>
)}
</Suspense>
</PageScroller>
</div>
</>
</PageShell>
)}
{/* 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
+14 -6
View File
@@ -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 (
<div className="p-4 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
</>
);
}
+25 -17
View File
@@ -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 (
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted mb-4 leading-relaxed">
{t("channels.discovery.intro")}
</p>
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar>
<div className="px-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
</div>
</PageToolbar>
<div className="px-4 pb-4 max-w-7xl mx-auto">
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
</>
);
}
+20 -4
View File
@@ -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<HTMLDivElement | null>(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 <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
return (
<PageScroller>
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
@@ -365,8 +374,14 @@ export default function ChannelPage({
</div>
</div>
</div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
</PageToolbar>
{/* 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. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? (
<>
<Feed
@@ -466,6 +481,7 @@ export default function ChannelPage({
)}
</div>
)}
</PageScroller>
</PageToolbarSlot.Provider>
</>
);
}
+82 -67
View File
@@ -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 = (
<div className="px-4 pt-4 max-w-7xl mx-auto">
<div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => (
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
@@ -399,24 +400,15 @@ export default function Channels({
</div>
);
if (view === "discovery") {
return (
<>
{tabs}
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</>
);
}
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). */}
<PageToolbar>
{tabs}
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
{view === "subscribed" && (
<div className="px-4 pt-3 pb-2 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
@@ -535,57 +527,80 @@ export default function Channels({
{t("channels.tags.manage")}
</button>
</div>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{/* Channel table */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => 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 && (
<div className="mt-6 border-t border-border pt-4">
<div className="flex items-center gap-2 mb-2">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</div>
)}
</div>
)}
</PageToolbar>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</>
)}
</div>
) : (
/* 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). */
<div className="px-4 pb-4 max-w-7xl mx-auto">
{/* 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 ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => 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")}
/>
)}
</div>
)}
</>
);
}
+11 -5
View File
@@ -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 (
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
<>
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
@@ -264,6 +269,7 @@ export default function ConfigPanel() {
}}
/>
</div>
</>
);
}
+27 -4
View File
@@ -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<T>({
rowClassName,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
controlsBandClassName,
externalFilter,
resetFiltersToken,
}: {
@@ -97,6 +100,11 @@ export default function DataTable<T>({
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<T>({
</div>
) : null;
const topControls =
controlsInBand && controls ? (
<PageToolbar>
<div className={controlsBandClassName}>{controls}</div>
</PageToolbar>
) : (
controls
);
return (
<div>
{(controlsPosition === "top" || controlsPosition === "both") && controls}
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
<div className="hidden md:block">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-muted border-b border-border">
{/* 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 <tr>'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). */}
<thead className="sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => {
const sorted_ = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
@@ -359,7 +382,7 @@ export default function DataTable<T>({
<th
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`relative font-medium px-2 py-2 whitespace-nowrap ${align(col.align)}`}
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${align(col.align)}`}
>
<span className="inline-flex items-center gap-1">
<button
+9 -1
View File
@@ -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 (
<div className="p-4 max-w-4xl w-full mx-auto">
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
@@ -613,7 +617,10 @@ export default function DownloadCenter({ me }: { me: Me }) {
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
@@ -739,5 +746,6 @@ export default function DownloadCenter({ me }: { me: Me }) {
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense>
</div>
</>
);
}
+19 -12
View File
@@ -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,18 +655,24 @@ export default function Feed({
);
return (
<div className="p-4">
{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
page being itself. */}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
{/* 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. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
+11 -3
View File
@@ -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 (
<div className="p-4 max-w-3xl w-full mx-auto space-y-4">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
@@ -130,7 +134,10 @@ export default function NotificationsPanel({
</div>
)}
</div>
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
@@ -185,7 +192,8 @@ export default function NotificationsPanel({
)}
</div>
)}
</div>
</div>
</>
);
}
+88 -8
View File
@@ -1,10 +1,39 @@
import { type ReactNode } from "react";
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
// The page's scroll container. Two of these exist and are never both mounted: App renders one for
// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page
// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather
// than owning a scroller — which is why the edge-fade lives here once instead of three times.
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
// the same view returns. Keyed by the shell's `scrollKey`.
const savedScroll = new Map<string, number>();
// 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,64 @@ 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 }) {
const fade = useScrollFade();
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(20, fadeTop);
const fadeRef = fade.ref;
const elRef = useRef<HTMLElement | null>(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 (
<main
ref={fade.ref}
ref={setRef}
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
>
+67
View File
@@ -0,0 +1,67 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
// 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 <PageToolbarSlot.Provider>
// 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<HTMLElement | null>(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(PageToolbarSlot);
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
// 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 band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
// module via <PageToolbar>. 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.
//
// 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,
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<HTMLDivElement | null>(null);
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
</div>
</PageToolbarSlot.Provider>
);
}
+8 -1
View File
@@ -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 (
<div className="px-4 pb-4">
<div className="px-4 pb-4 pt-3">
{selectedId == null || !detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
</div>
) : (
<>
{/* Header (title, actions, sort/group) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
@@ -624,6 +629,8 @@ export default function Playlists({
</div>
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
+9 -2
View File
@@ -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 (
<div className="p-4 max-w-[1600px] mx-auto">
<p className="text-xs text-muted mb-3">
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
<p className="text-xs text-muted">
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
</p>
</p>
</div>
</PageToolbar>
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
+4 -2
View File
@@ -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."
}
}
+4 -2
View File
@@ -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."
}
}
+5 -2
View File
@@ -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 } };