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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user