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.
486 lines
21 KiB
TypeScript
486 lines
21 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
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";
|
||
import { api, type FeedFilters, type Me } from "../lib/api";
|
||
import type { FeedView } from "../lib/feedView";
|
||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||
|
||
// --- About-tab metadata helpers ---
|
||
// ISO 3166-1 alpha-2 code → 🇺🇸 regional-indicator flag emoji.
|
||
function countryFlag(code: string): string {
|
||
if (!/^[A-Za-z]{2}$/.test(code)) return "";
|
||
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65));
|
||
}
|
||
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||
try {
|
||
return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code;
|
||
} catch {
|
||
return code;
|
||
}
|
||
}
|
||
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||
function parseKeywords(s: string): string[] {
|
||
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||
}
|
||
// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated.
|
||
function topicLabels(urls: string[]): string[] {
|
||
const seen = new Set<string>();
|
||
for (const url of urls) {
|
||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||
let label = seg;
|
||
try {
|
||
label = decodeURIComponent(seg);
|
||
} catch {
|
||
/* keep raw */
|
||
}
|
||
label = label.replace(/_/g, " ").trim();
|
||
if (label) seen.add(label);
|
||
}
|
||
return [...seen];
|
||
}
|
||
|
||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
|
||
// and offers "load more" to page deeper — without permanently polluting everyone's library
|
||
// (the backend keeps explored videos private to this user until they subscribe).
|
||
export default function ChannelPage({
|
||
channelId,
|
||
initialName,
|
||
me,
|
||
view,
|
||
setView,
|
||
onShowInFeed,
|
||
}: {
|
||
channelId: string;
|
||
initialName?: string;
|
||
me: Me;
|
||
// The feed's view pref, passed straight through to the Feed this page renders — so its
|
||
// toolbar carries the same switcher and a change here is the same account-wide choice.
|
||
view: FeedView;
|
||
setView: (v: FeedView) => void;
|
||
// Narrow the real feed to this channel and go there. Offered only for a channel you're
|
||
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
|
||
// follow would just land you on an empty page.
|
||
onShowInFeed: () => void;
|
||
}) {
|
||
const { t, i18n } = useTranslation();
|
||
const { closeChannel } = useNavigationActions();
|
||
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
|
||
// succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and
|
||
// only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare).
|
||
const MAX_BANNER_RETRIES = 3;
|
||
const [bannerAttempt, setBannerAttempt] = useState(0);
|
||
|
||
const detail = useQuery({
|
||
queryKey: ["channel", channelId],
|
||
queryFn: () => api.channelDetail(channelId),
|
||
});
|
||
const ch = detail.data;
|
||
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
||
|
||
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||
const [exploring, setExploring] = useState(false);
|
||
const autoTried = useRef<string | null>(null);
|
||
|
||
const runExplore = useCallback(
|
||
(token?: string | null) => {
|
||
setExploring(true);
|
||
return api
|
||
.exploreChannel(channelId, token ?? undefined)
|
||
.then((r) => {
|
||
setNextToken(r.next_page_token);
|
||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
||
// first visit (the GET that drove this render predated the explore that just ran).
|
||
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||
})
|
||
.finally(() => setExploring(false));
|
||
},
|
||
[channelId, qc]
|
||
);
|
||
|
||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
||
// account (explore spends shared quota) and when videos are already present (a revisit, or a
|
||
// subscribed channel whose uploads the scheduler backfills).
|
||
useEffect(() => {
|
||
if (!ch || me.is_demo) return;
|
||
if (autoTried.current === channelId) return;
|
||
if (ch.subscribed || ch.blocked) return;
|
||
if (ch.explored && ch.stored_videos > 0) return;
|
||
autoTried.current = channelId;
|
||
runExplore().catch(() => {});
|
||
}, [ch, channelId, me.is_demo, runExplore]);
|
||
|
||
const block = useMutation({
|
||
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||
},
|
||
});
|
||
|
||
const subscribe = useMutation({
|
||
mutationFn: () => api.subscribeChannel(channelId),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
|
||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
|
||
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
||
},
|
||
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
|
||
// here, so no Connect button — but the user at least sees why it didn't work).
|
||
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
|
||
});
|
||
const unsubscribe = useMutation({
|
||
mutationFn: () => api.unsubscribeChannel(channelId),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||
},
|
||
});
|
||
const onUnsubscribe = async () => {
|
||
const ok = await confirm({
|
||
title: t("channel.unsubTitle"),
|
||
message: t("channel.unsubBody", { name: ch?.title ?? channelId }),
|
||
confirmLabel: t("channel.unsubscribe"),
|
||
danger: true,
|
||
});
|
||
if (ok) unsubscribe.mutate();
|
||
};
|
||
|
||
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
|
||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
||
tags: [],
|
||
tagMode: "or",
|
||
q: "",
|
||
sort: "newest",
|
||
scope: "all",
|
||
librarySource: "all",
|
||
includeNormal: true,
|
||
includeShorts: false,
|
||
includeLive: true,
|
||
show: "all",
|
||
channelIds: [channelId],
|
||
}));
|
||
|
||
const name = ch?.title ?? initialName ?? channelId;
|
||
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
||
const joined = ch?.published_at
|
||
? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" })
|
||
: null;
|
||
// Handle + stats on one compact meta line under the name (saves the separate stats row).
|
||
const metaParts = [
|
||
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
|
||
ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
|
||
ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
|
||
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||
joined ? t("channel.joined", { date: joined }) : null,
|
||
].filter(Boolean) as string[];
|
||
const topics = ch?.topic_categories?.length ? topicLabels(ch.topic_categories) : [];
|
||
const keywords = ch?.keywords ? parseKeywords(ch.keywords) : [];
|
||
const hasAboutDetails =
|
||
!!ch?.country || !!ch?.default_language || topics.length > 0 || keywords.length > 0;
|
||
const tabClass = (active: boolean) =>
|
||
active
|
||
? "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.
|
||
return (
|
||
<>
|
||
{/* 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 ? (
|
||
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||
<div
|
||
className="w-full overflow-hidden rounded-2xl bg-surface"
|
||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||
>
|
||
<img
|
||
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
|
||
// browser's negative cache (googleusercontent accepts an extra query param).
|
||
key={bannerAttempt}
|
||
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
|
||
alt=""
|
||
className="w-full h-full object-cover object-center"
|
||
onError={() => {
|
||
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||
window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1));
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
|
||
// overlapping avatar + the Back button have the same room and don't collide (a short bar
|
||
// let the -mt-8 avatar ride up into the Back button).
|
||
<div
|
||
className="w-full rounded-2xl bg-surface"
|
||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||
/>
|
||
)}
|
||
<button
|
||
data-testid="channel-back"
|
||
onClick={closeChannel}
|
||
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
{t("channel.back")}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="px-4 sm:px-6">
|
||
{/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner
|
||
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
|
||
<div className="relative z-10 flex items-end gap-4 -mt-8">
|
||
<Avatar
|
||
src={ch?.thumbnail_url ?? null}
|
||
fallback={name}
|
||
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
||
/>
|
||
<div className="flex-1 min-w-0 pb-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">{name}</h1>
|
||
{ch?.blocked ? (
|
||
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||
{t("channel.blockedBadge")}
|
||
</span>
|
||
) : (
|
||
ch?.explored &&
|
||
!ch?.subscribed && (
|
||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||
{t("channel.exploringBadge")}
|
||
</span>
|
||
)
|
||
)}
|
||
</div>
|
||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||
{metaParts.map((part, i) => (
|
||
<span key={i}>
|
||
{i > 0 && <span className="mr-1.5">·</span>}
|
||
{part}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{exploring && (
|
||
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
{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="px-4 sm:px-6 pt-2 flex items-center gap-4">
|
||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||
{t("channel.tabVideos")}
|
||
</button>
|
||
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
|
||
{t("channel.tabAbout")}
|
||
</button>
|
||
<div className="ml-2 flex items-center gap-2 pb-1.5">
|
||
<a
|
||
href={ytUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={t("channels.row.openOnYouTube")}
|
||
aria-label={t("channels.row.openOnYouTube")}
|
||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
|
||
>
|
||
<ExternalLink className="w-4 h-4" />
|
||
</a>
|
||
{ch?.subscribed && (
|
||
<button
|
||
onClick={onShowInFeed}
|
||
title={t("channel.showInFeed")}
|
||
aria-label={t("channel.showInFeed")}
|
||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
|
||
>
|
||
<SlidersHorizontal className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
{!me.is_demo && (
|
||
<button
|
||
onClick={() => block.mutate()}
|
||
disabled={block.isPending}
|
||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||
ch?.blocked
|
||
? "border-danger text-danger"
|
||
: "border-border text-muted hover:text-danger hover:border-danger"
|
||
}`}
|
||
>
|
||
<Ban className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
{!me.is_demo &&
|
||
!ch?.blocked &&
|
||
(ch?.subscribed ? (
|
||
<button
|
||
onClick={onUnsubscribe}
|
||
disabled={unsubscribe.isPending}
|
||
title={t("channel.unsubscribe")}
|
||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
||
>
|
||
<Check className="w-4 h-4" />
|
||
{t("channel.subscribedState")}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => subscribe.mutate()}
|
||
disabled={subscribe.isPending}
|
||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
{t("channel.subscribe")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div ref={setChromeSlot} />
|
||
</div>
|
||
|
||
{/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */}
|
||
<PageToolbarSlot.Provider value={chromeSlot}>
|
||
{tab === "videos" ? (
|
||
<>
|
||
<Feed
|
||
filters={filters}
|
||
setFilters={setFilters}
|
||
view={view}
|
||
setView={setView}
|
||
canRead={me.can_read}
|
||
isDemo={me.is_demo}
|
||
onOpenWizard={() => {}}
|
||
ytSearch={null}
|
||
onYtSearch={() => {}}
|
||
onExitYtSearch={() => {}}
|
||
channelScoped
|
||
/>
|
||
{!ch?.subscribed && nextToken && (
|
||
<div className="flex justify-center pb-6">
|
||
<button
|
||
onClick={() => runExplore(nextToken)}
|
||
disabled={exploring}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
||
>
|
||
{exploring ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="w-4 h-4" />
|
||
)}
|
||
{t("channel.loadMore")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||
{ch?.description ? (
|
||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
|
||
) : (
|
||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||
)}
|
||
{ch?.external_links && ch.external_links.length > 0 && (
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{ch.external_links.map((l, i) => (
|
||
<a
|
||
key={i}
|
||
href={l.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
{l.title || l.url}
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
{hasAboutDetails && (
|
||
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
|
||
{ch?.country && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
|
||
<span>
|
||
{countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{ch?.default_language && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||
<span>{displayName(ch.default_language, i18n.language, "language")}</span>
|
||
</div>
|
||
)}
|
||
{topics.length > 0 && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{topics.map((tp) => (
|
||
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||
{tp}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{keywords.length > 0 && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{keywords.map((kw, i) => (
|
||
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||
{kw}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</PageToolbarSlot.Provider>
|
||
</>
|
||
);
|
||
}
|