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:
npeter83 2026-06-30 03:08:52 +02:00
parent bc4c362423
commit cc1e670202
18 changed files with 744 additions and 120 deletions

View file

@ -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>