Replace yellow accent (#f5a623) with white (#ffffff) across the entire app. Flatten VideoCard grid variant by removing zinc-900 card background so content sits directly on the page. Simplify active states, badges, progress bars, and hover effects throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
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,
|
|
} 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 (
|
|
<div className={`rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden flex flex-col transition-opacity duration-300 ${
|
|
followMut.isSuccess || dismissMut.isSuccess ? "opacity-0 pointer-events-none" : "opacity-100"
|
|
}`}>
|
|
{/* Featured thumbnail */}
|
|
{featured ? (
|
|
<div
|
|
className="relative aspect-video bg-zinc-800 overflow-hidden cursor-pointer group/thumb"
|
|
onClick={() => navigate(`/channels/${item.channel_id}`)}
|
|
>
|
|
<img
|
|
src={featured.thumbnail_url}
|
|
alt={featured.title}
|
|
className="w-full h-full object-cover group-hover/thumb:scale-105 transition-transform duration-300"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-zinc-900/80 to-transparent" />
|
|
<div className="absolute bottom-2 left-3">
|
|
{item.thumbnail_url ? (
|
|
<img src={item.thumbnail_url} alt={item.name}
|
|
className="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-900" />
|
|
) : (
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white text-sm ring-2 ring-zinc-900"
|
|
style={{ backgroundColor: avatarColor(item.name) }}>
|
|
{item.name?.[0]?.toUpperCase()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="h-20 cursor-pointer flex items-end px-3 pb-2"
|
|
style={{ background: `linear-gradient(135deg, ${avatarColor(item.name)}44, ${avatarColor(item.name)}22)` }}
|
|
onClick={() => navigate(`/channels/${item.channel_id}`)}
|
|
>
|
|
{item.thumbnail_url ? (
|
|
<img src={item.thumbnail_url} alt={item.name}
|
|
className="w-10 h-10 rounded-full object-cover ring-2 ring-zinc-900" />
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-base ring-2 ring-zinc-900"
|
|
style={{ backgroundColor: avatarColor(item.name) }}>
|
|
{item.name?.[0]?.toUpperCase()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Info */}
|
|
<div className="p-3 flex flex-col gap-2 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<button
|
|
onClick={() => navigate(`/channels/${item.channel_id}`)}
|
|
className="font-semibold text-sm text-zinc-100 hover:text-white text-left leading-tight line-clamp-1"
|
|
>
|
|
{item.name}
|
|
</button>
|
|
<p className="text-xs text-zinc-500 mt-0.5 truncate">
|
|
{[subs && `${subs} subscribers`, SOURCE_LABELS[item.source] ?? "Recommended"].filter(Boolean).join(" · ")}
|
|
</p>
|
|
</div>
|
|
{!followMut.isSuccess && (
|
|
<button
|
|
onClick={() => dismissMut.mutate()}
|
|
disabled={busy}
|
|
title="Not interested"
|
|
className="p-1 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors disabled:opacity-40 shrink-0"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{rest.length > 0 && (
|
|
<ul className="flex flex-col gap-0.5">
|
|
{rest.slice(0, 2).map((v, i) => (
|
|
<li key={i} className="text-xs text-zinc-500 truncate">· {v.title}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{!featured && item.description && (
|
|
<p className="text-xs text-zinc-500 line-clamp-2">{item.description}</p>
|
|
)}
|
|
|
|
<div className="mt-auto pt-1">
|
|
{followMut.isSuccess ? (
|
|
<p className="text-xs text-accent font-medium text-center py-1">Following ✓</p>
|
|
) : (
|
|
<button
|
|
onClick={() => followMut.mutate()}
|
|
disabled={busy}
|
|
className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
|
>
|
|
Follow
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Tab({ active, onClick, children, count }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={[
|
|
"px-3 py-1.5 text-xs font-medium border-b-2 transition-colors -mb-px flex items-center gap-1.5",
|
|
active
|
|
? "border-accent text-zinc-100"
|
|
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
|
].join(" ")}
|
|
>
|
|
{children}
|
|
{count > 0 && (
|
|
<span className={`text-xs px-1.5 py-0.5 rounded-full ${active ? "bg-accent/20 text-accent" : "bg-zinc-800 text-zinc-500"}`}>
|
|
{count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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 refreshMut = useMutation({
|
|
mutationFn: refreshDiscovery,
|
|
onSuccess: () => setTimeout(() => {
|
|
qc.invalidateQueries({ queryKey: ["discovery"] });
|
|
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
|
}, 8000),
|
|
});
|
|
|
|
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 (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
|
|
<button
|
|
onClick={() => refreshMut.mutate()}
|
|
disabled={refreshMut.isPending}
|
|
className="flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
|
>
|
|
{refreshMut.isPending && (
|
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
</svg>
|
|
)}
|
|
{refreshMut.isPending ? "Searching…" : "Find more"}
|
|
</button>
|
|
</div>
|
|
|
|
{refreshMut.isSuccess && !refreshMut.isPending && (
|
|
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-300">
|
|
Searching YouTube for new channels — results will appear in a few seconds.
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-1 border-b border-zinc-800">
|
|
<Tab active={tab === "channels"} onClick={() => setTab("channels")} count={0}>
|
|
Channels
|
|
</Tab>
|
|
<Tab active={tab === "videos"} onClick={() => setTab("videos")} count={0}>
|
|
Videos
|
|
</Tab>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-24">
|
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
) : isEmpty ? (
|
|
<div className="flex flex-col items-center gap-4 py-24 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
|
|
<svg className="w-7 h-7 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="text-zinc-500 text-sm">Nothing here yet</p>
|
|
<p className="text-zinc-500 text-sm mt-1 max-w-xs">
|
|
Follow a few channels first, then hit "Find more" to discover similar ones.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => refreshMut.mutate()}
|
|
disabled={refreshMut.isPending}
|
|
className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-zinc-100 transition-colors disabled:opacity-60"
|
|
>
|
|
{refreshMut.isPending ? "Searching…" : "Find channels"}
|
|
</button>
|
|
</div>
|
|
) : tab === "channels" ? (
|
|
<>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{channels.map((item) => (
|
|
<ChannelCard key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-center gap-3 pt-2">
|
|
<button
|
|
onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
|
|
disabled={channelPage === 0}
|
|
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
← Prev
|
|
</button>
|
|
<span className="text-zinc-500 text-xs tabular-nums">Page {channelPage + 1}</span>
|
|
<button
|
|
onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
|
|
disabled={!hasNextChannelPage}
|
|
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col gap-2">
|
|
{visibleVideos.map((v) => (
|
|
<VideoCard
|
|
key={v.youtube_video_id}
|
|
video={{ ...v, is_recommended: true }}
|
|
variant="list"
|
|
onDismiss={handleDismissVideo}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-center gap-3 pt-2">
|
|
<button
|
|
onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
|
|
disabled={videoPage === 0}
|
|
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
← Prev
|
|
</button>
|
|
<span className="text-zinc-500 text-xs tabular-nums">Page {videoPage + 1}</span>
|
|
<button
|
|
onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
|
|
disabled={!hasNextVideoPage}
|
|
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-xs font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|