feat(channels): dedicated channel page + ephemeral explore UI
Frontend for the channel-explore feature: - ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests recent uploads on first visit (background, with a loading note) + "Load more from YouTube" to page deeper; skipped for demo / already-subscribed channels. - App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays. - ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter → onOpenChannel); the in-card "only this channel" filter button is dropped (the page subsumes it). PlayerModal channel-name wiring follows in the next commit. - api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types. - i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group, channels_explore quota label.
This commit is contained in:
parent
bc4c362423
commit
cc1e670202
18 changed files with 744 additions and 120 deletions
293
frontend/src/components/ChannelPage.tsx
Normal file
293
frontend/src/components/ChannelPage.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
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"] });
|
||||
})
|
||||
.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 ? (
|
||||
<div
|
||||
className="h-24 sm:h-36 w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${ch.banner_url})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-14 w-full bg-surface" />
|
||||
)}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-3 left-3 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 */}
|
||||
<div className="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}
|
||||
/>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ export default function Feed({
|
|||
ytSearch,
|
||||
onYtSearch,
|
||||
onExitYtSearch,
|
||||
onOpenChannel,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
|
|
@ -91,6 +92,9 @@ export default function Feed({
|
|||
ytSearch: string | null;
|
||||
onYtSearch: (q: string) => void;
|
||||
onExitYtSearch: () => void;
|
||||
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
|
||||
// the cards via VirtualFeed.
|
||||
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
|
|
@ -257,11 +261,11 @@ export default function Feed({
|
|||
[qc]
|
||||
);
|
||||
|
||||
const onChannelFilter = useCallback(
|
||||
const handleOpenChannel = useCallback(
|
||||
(channelId: string, channelName: string) => {
|
||||
setFilters({ ...filters, channelId, channelName });
|
||||
onOpenChannel?.(channelId, channelName);
|
||||
},
|
||||
[filters, setFilters]
|
||||
[onOpenChannel]
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
|
|
@ -347,7 +351,7 @@ export default function Feed({
|
|||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={false}
|
||||
|
|
@ -568,7 +572,7 @@ export default function Feed({
|
|||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={!!hasNextPage}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
CheckCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ListFilter,
|
||||
Play,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
|
|
@ -21,13 +20,11 @@ function Actions({
|
|||
onState,
|
||||
onResetState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
}: {
|
||||
video: Video;
|
||||
onState: (id: string, status: string) => void;
|
||||
onResetState?: (id: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const act = (status: string) => (e: React.MouseEvent) => {
|
||||
|
|
@ -97,19 +94,6 @@ function Actions({
|
|||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onChannelFilter && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
title={t("card.onlyThisChannel")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
|
||||
>
|
||||
<ListFilter className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,7 +228,7 @@ function VideoCard({
|
|||
onState,
|
||||
onResetState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
onOpenChannel,
|
||||
onOpen,
|
||||
}: {
|
||||
video: Video;
|
||||
|
|
@ -252,7 +236,7 @@ function VideoCard({
|
|||
onState: (id: string, status: string) => void;
|
||||
onResetState?: (id: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
onOpenChannel?: (channelId: string, channelName: string) => void;
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
|
@ -303,18 +287,18 @@ function VideoCard({
|
|||
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{title}
|
||||
<a
|
||||
href={video.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</a>
|
||||
</button>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
</div>
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -335,17 +319,17 @@ function VideoCard({
|
|||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title}
|
||||
<a
|
||||
href={video.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</a>
|
||||
</button>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export interface VirtualFeedProps {
|
|||
view: "grid" | "list";
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter: (channelId: string, channelName: string) => void;
|
||||
onOpenChannel: (channelId: string, channelName: string) => void;
|
||||
onResetState: (id: string) => void;
|
||||
onOpen: (v: Video, startAt?: number | null) => void;
|
||||
hasNextPage: boolean;
|
||||
|
|
@ -53,7 +53,7 @@ export default function VirtualFeed({
|
|||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
onOpenChannel,
|
||||
onResetState,
|
||||
onOpen,
|
||||
hasNextPage,
|
||||
|
|
@ -149,7 +149,7 @@ export default function VirtualFeed({
|
|||
view="grid"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
|
|
@ -162,7 +162,7 @@ export default function VirtualFeed({
|
|||
view="list"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue