feat(shell): sticky tabs+toolbar on the channel page

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.
This commit is contained in:
2026-07-18 04:45:11 +02:00
parent 10f4baab3b
commit 0e03e561eb
6 changed files with 75 additions and 30 deletions
+15 -2
View File
@@ -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<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
@@ -294,11 +299,16 @@ export default function ChannelPage({
{t("channel.loadingVideos")}
</div>
)}
</div>
{/* 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. */}
<div className="sticky top-0 z-20 bg-surface border-b border-border">
{/* 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. */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<div className="px-4 sm:px-6 pt-2 flex items-center gap-4">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
@@ -365,9 +375,11 @@ export default function ChannelPage({
))}
</div>
</div>
<div ref={setChromeSlot} />
</div>
{/* Tab content */}
{/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? (
<>
<Feed
@@ -467,6 +479,7 @@ export default function ChannelPage({
)}
</div>
)}
</PageToolbarSlot.Provider>
</>
);
}