2026-06-30 03:08:52 +02:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
|
import { ArrowLeft, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
|
|
|
|
|
|
import Avatar from "./Avatar";
|
|
|
|
|
|
import Feed from "./Feed";
|
|
|
|
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
|
|
|
|
import { notify } from "../lib/notifications";
|
|
|
|
|
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
|
|
|
|
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
onBack,
|
|
|
|
|
|
onOpenChannel,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
channelId: string;
|
|
|
|
|
|
initialName?: string;
|
|
|
|
|
|
me: Me;
|
|
|
|
|
|
view: "grid" | "list";
|
|
|
|
|
|
onBack: () => void;
|
|
|
|
|
|
onOpenChannel: (id: string, name?: string) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { t, i18n } = useTranslation();
|
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
|
const confirm = useConfirm();
|
|
|
|
|
|
const [tab, setTab] = useState<"videos" | "about">("videos");
|
|
|
|
|
|
|
|
|
|
|
|
const detail = useQuery({
|
|
|
|
|
|
queryKey: ["channel", channelId],
|
|
|
|
|
|
queryFn: () => api.channelDetail(channelId),
|
|
|
|
|
|
});
|
|
|
|
|
|
const ch = detail.data;
|
|
|
|
|
|
|
|
|
|
|
|
// 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"] });
|
2026-06-30 04:17:49 +02:00
|
|
|
|
// 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] });
|
2026-06-30 03:08:52 +02:00
|
|
|
|
})
|
|
|
|
|
|
.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) return;
|
|
|
|
|
|
if (ch.explored && ch.stored_videos > 0) return;
|
|
|
|
|
|
autoTried.current = channelId;
|
|
|
|
|
|
runExplore().catch(() => {});
|
|
|
|
|
|
}, [ch, channelId, me.is_demo, runExplore]);
|
|
|
|
|
|
|
|
|
|
|
|
const subscribe = useMutation({
|
|
|
|
|
|
mutationFn: () => api.subscribeChannel(channelId),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
|
|
|
|
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
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",
|
|
|
|
|
|
channelId,
|
|
|
|
|
|
channelName: initialName,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-30 04:43:37 +02:00
|
|
|
|
// 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[];
|
2026-06-30 03:08:52 +02:00
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
|
|
|
|
{/* Banner + back */}
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
{ch?.banner_url ? (
|
2026-06-30 04:32:43 +02:00
|
|
|
|
// 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 — at full width.
|
2026-06-30 04:43:37 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className="w-full overflow-hidden bg-surface"
|
|
|
|
|
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
|
|
|
|
|
>
|
2026-06-30 04:24:52 +02:00
|
|
|
|
<img
|
2026-06-30 04:32:43 +02:00
|
|
|
|
src={`${ch.banner_url}=w1707`}
|
2026-06-30 04:24:52 +02:00
|
|
|
|
alt=""
|
2026-06-30 04:32:43 +02:00
|
|
|
|
className="w-full h-full object-cover object-center"
|
2026-06-30 04:24:52 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-06-30 03:08:52 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="h-14 w-full bg-surface" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onBack}
|
2026-06-30 04:24:52 +02:00
|
|
|
|
className="absolute top-3 left-3 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"
|
2026-06-30 03:08:52 +02:00
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
|
{t("channel.back")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="px-4 sm:px-6">
|
2026-06-30 04:24:52 +02:00
|
|
|
|
{/* 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">
|
2026-06-30 03:08:52 +02:00
|
|
|
|
<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 className="text-xl font-semibold truncate">{name}</h1>
|
|
|
|
|
|
{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>
|
2026-06-30 04:43:37 +02:00
|
|
|
|
<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>
|
2026-06-30 03:08:52 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0 pb-1">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
{!me.is_demo &&
|
|
|
|
|
|
(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>
|
|
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Tabs */}
|
2026-06-30 04:43:37 +02:00
|
|
|
|
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
2026-06-30 03:08:52 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Tab content */}
|
|
|
|
|
|
{tab === "videos" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Feed
|
|
|
|
|
|
filters={filters}
|
|
|
|
|
|
setFilters={setFilters}
|
|
|
|
|
|
view={view}
|
|
|
|
|
|
canRead={me.can_read}
|
|
|
|
|
|
isDemo={me.is_demo}
|
|
|
|
|
|
onOpenWizard={() => {}}
|
|
|
|
|
|
ytSearch={null}
|
|
|
|
|
|
onYtSearch={() => {}}
|
|
|
|
|
|
onExitYtSearch={() => {}}
|
|
|
|
|
|
onOpenChannel={onOpenChannel}
|
2026-06-30 04:37:00 +02:00
|
|
|
|
channelScoped
|
2026-06-30 03:08:52 +02:00
|
|
|
|
/>
|
|
|
|
|
|
{!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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|