import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getDiscovery, getDiscoveryVideos,
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
getDiscoveryStatus,
} from "../api";
import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 50;
const SOURCE_LABELS = {
search: "Based on your channels",
graph: "Related to channels you follow",
community: "Popular with other users",
category: "Similar category",
liked: "Related to videos you liked",
};
function formatSubs(n) {
if (!n) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
}
function avatarColor(name) {
if (!name) return "#52525b";
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return `hsl(${h}, 55%, 42%)`;
}
function ChannelCard({ item }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [gone, setGone] = useState(false);
const followMut = useMutation({
mutationFn: () => followDiscovery(item.channel_id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
setTimeout(() => {
setGone(true);
qc.invalidateQueries({ queryKey: ["discovery"] });
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
}, 600);
},
});
const dismissMut = useMutation({
mutationFn: () => dismissDiscovery(item.channel_id),
onSuccess: () => {
setTimeout(() => {
setGone(true);
qc.invalidateQueries({ queryKey: ["discovery"] });
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
}, 300);
},
});
const [featured, ...rest] = item.preview_videos ?? [];
const subs = formatSubs(item.subscriber_count);
const busy = followMut.isPending || dismissMut.isPending;
if (gone) return null;
return (
{/* Featured thumbnail */}
{featured ? (
navigate(`/channels/${item.channel_id}`)}
>
{item.thumbnail_url ? (

) : (
{item.name?.[0]?.toUpperCase()}
)}
) : (
navigate(`/channels/${item.channel_id}`)}
>
{item.thumbnail_url ? (

) : (
{item.name?.[0]?.toUpperCase()}
)}
)}
{/* Info */}
{[subs && `${subs} subscribers`, SOURCE_LABELS[item.source] ?? "Recommended"].filter(Boolean).join(" · ")}
{!followMut.isSuccess && (
)}
{rest.length > 0 && (
{rest.slice(0, 2).map((v, i) => (
- · {v.title}
))}
)}
{!featured && item.description && (
{item.description}
)}
{followMut.isSuccess ? (
Following ✓
) : (
)}
);
}
function Tab({ active, onClick, children, count }) {
return (
);
}
export default function DiscoveryPage() {
const qc = useQueryClient();
const [tab, setTab] = useState("channels");
const [channelPage, setChannelPage] = useState(0);
const [videoPage, setVideoPage] = useState(0);
const [dismissedVideos, setDismissedVideos] = useState(new Set());
const { data: channels = [], isLoading: loadingChannels } = useQuery({
queryKey: ["discovery", channelPage],
queryFn: () => getDiscovery(channelPage * PAGE_SIZE, PAGE_SIZE).then((r) => r.data),
staleTime: 0,
placeholderData: (prev) => prev,
});
const { data: videos = [], isLoading: loadingVideos } = useQuery({
queryKey: ["discovery-videos", videoPage],
queryFn: () => getDiscoveryVideos(videoPage * PAGE_SIZE, PAGE_SIZE).then((r) => r.data),
staleTime: 0,
placeholderData: (prev) => prev,
});
const { data: discStatus } = useQuery({
queryKey: ["discovery-status"],
queryFn: () => getDiscoveryStatus().then(r => r.data),
staleTime: 10_000,
refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
});
const refreshMut = useMutation({
mutationFn: refreshDiscovery,
onSuccess: () => {
// Discovery runs as a background job and takes several minutes.
// Invalidate status immediately so the "queued" state shows, then
// re-check every 2 minutes until results land.
qc.invalidateQueries({ queryKey: ["discovery-status"] });
},
});
const handleDismissVideo = (video) => {
setDismissedVideos(prev => new Set([...prev, video.youtube_video_id]));
dismissDiscoveryVideo(video.youtube_video_id).then(() => {
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
});
};
const visibleVideos = videos.filter(v => !dismissedVideos.has(v.youtube_video_id));
const isEmpty = tab === "channels" ? channels.length === 0 : visibleVideos.length === 0;
const isLoading = tab === "channels" ? loadingChannels : loadingVideos;
const hasNextChannelPage = channels.length === PAGE_SIZE;
const hasNextVideoPage = videos.length === PAGE_SIZE;
return (
{/* Header */}
Discover
{discStatus && (
{discStatus.pending_count > 0
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
: "Queue empty"}
{discStatus.last_run
? ` · last refreshed ${new Date(discStatus.last_run + "Z").toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`
: " · never refreshed"}
)}
{refreshMut.isSuccess && !discStatus?.progress?.running && (
Discovery is running — progress shows in the top bar. Searches are spaced out over ~20 minutes. Runs automatically every day.
)}
{/* Tabs */}
setTab("channels")} count={0}>
Channels
setTab("videos")} count={0}>
Videos
{/* Content */}
{isLoading ? (
) : isEmpty ? (
Nothing here yet
Follow a few channels first, then hit "Find more" to discover similar ones.
) : tab === "channels" ? (
<>
{channels.map((item) => (
))}
Page {channelPage + 1}
>
) : (
<>
{visibleVideos.map((v) => (
))}
Page {videoPage + 1}
>
)}
);
}