Initial commit — YT Hub
Self-hosted personal YouTube management app. FastAPI + SQLite backend, React + Vite + Tailwind frontend. Dockerfiles and compose included for Portainer deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
356
frontend/src/pages/Discovery.jsx
Normal file
356
frontend/src/pages/Discovery.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
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";
|
||||
|
||||
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-yellow-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ active, onClick, children, count }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2",
|
||||
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-300 font-medium">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-yellow-300 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); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
disabled={channelPage === 0}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span>
|
||||
<button
|
||||
onClick={() => { setChannelPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
disabled={!hasNextChannelPage}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{visibleVideos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={{ ...v, is_recommended: true }}
|
||||
onDismiss={handleDismissVideo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setVideoPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
disabled={videoPage === 0}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span>
|
||||
<button
|
||||
onClick={() => { setVideoPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
disabled={!hasNextVideoPage}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user