siftlode/frontend/src/components/ChannelPage.tsx

305 lines
12 KiB
TypeScript
Raw Normal View History

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"] });
// 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) 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;
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 ? (
// 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.
<div className="w-full overflow-hidden bg-surface" style={{ aspectRatio: "2560 / 423" }}>
<img
src={`${ch.banner_url}=w1707`}
alt=""
className="w-full h-full object-cover object-center"
/>
</div>
) : (
<div className="h-14 w-full bg-surface" />
)}
<button
onClick={onBack}
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"
>
<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 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>
{ch?.handle && (
<div className="text-sm text-muted truncate">@{ch.handle.replace(/^@/, "")}</div>
)}
</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>
{/* Stats */}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted mt-3">
{ch?.subscriber_count != null && (
<span>{t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })}</span>
)}
{ch?.video_count != null && <span>· {t("channel.videoCount", { count: ch.video_count })}</span>}
{ch?.total_view_count != null && (
<span>· {t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })}</span>
)}
{joined && <span>· {t("channel.joined", { date: joined })}</span>}
</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 */}
<div className="flex items-center gap-4 mt-4 border-b border-border">
<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}
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>
)}
</div>
)}
</main>
);
}