- New playlists router: fetch channel playlists from YouTube, index playlist videos, browse by playlist with pagination - Playlist model gets video_ids column to store ordered video list - Register playlists router in main.py with DB migration - Add Playlists tab to Channel page: grid of playlist cards, click to browse videos, index/re-index per playlist - Fix explore older videos skipping all entries without published_at; flat-playlist entries for older videos rarely include timestamp data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
509 lines
22 KiB
JavaScript
509 lines
22 KiB
JavaScript
import { useState, useEffect, useRef } from "react";
|
||
import { useParams } from "react-router-dom";
|
||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
|
||
import {
|
||
getChannel, getChannelVideos, searchChannelYoutube,
|
||
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
|
||
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
|
||
} from "../api";
|
||
import VideoCard from "../components/VideoCard";
|
||
|
||
const LIMIT = 60;
|
||
|
||
const TABS = [
|
||
{ value: "videos", label: "Videos" },
|
||
{ value: "popular", label: "Popular" },
|
||
{ value: "playlists", label: "Playlists" },
|
||
];
|
||
|
||
const SORTS = [
|
||
{ value: "newest", label: "Newest" },
|
||
{ value: "oldest", label: "Oldest" },
|
||
{ value: "title", label: "A–Z" },
|
||
{ value: "unwatched", label: "Unwatched" },
|
||
];
|
||
|
||
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);
|
||
}
|
||
|
||
export default function ChannelPage() {
|
||
const { id } = useParams();
|
||
const qc = useQueryClient();
|
||
const [tab, setTab] = useState("videos");
|
||
const [sort, setSort] = useState("newest");
|
||
const [search, setSearch] = useState("");
|
||
const [activeQ, setActiveQ] = useState("");
|
||
const [indexing, setIndexing] = useState(false);
|
||
const [explorePage, setExplorePage] = useState(2);
|
||
const searchInputRef = useRef(null);
|
||
const [openPlaylistId, setOpenPlaylistId] = useState(null);
|
||
const [playlistOffset, setPlaylistOffset] = useState(0);
|
||
|
||
const { data: channel, isLoading: loadingChannel } = useQuery({
|
||
queryKey: ["channel", id],
|
||
queryFn: () => getChannel(id).then((r) => r.data),
|
||
});
|
||
|
||
const effectiveSort = tab === "popular" ? "popular" : sort;
|
||
|
||
const {
|
||
data: videosData,
|
||
isLoading: loadingVideos,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
isFetchingNextPage,
|
||
} = useInfiniteQuery({
|
||
queryKey: ["channel-videos", id, effectiveSort, activeQ],
|
||
queryFn: ({ pageParam = 0 }) =>
|
||
getChannelVideos(id, effectiveSort, pageParam, LIMIT, activeQ).then((r) => r.data),
|
||
getNextPageParam: (lastPage, pages) =>
|
||
lastPage.length === LIMIT ? pages.length * LIMIT : undefined,
|
||
enabled: !!id,
|
||
});
|
||
|
||
const videos = videosData?.pages.flat() ?? [];
|
||
|
||
// Refetch after background re-index
|
||
const refetchedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (!id || refetchedRef.current) return;
|
||
refetchedRef.current = true;
|
||
const t = setTimeout(() => {
|
||
qc.invalidateQueries({ queryKey: ["channel", id] });
|
||
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
|
||
}, 8000);
|
||
return () => clearTimeout(t);
|
||
}, [id, qc]);
|
||
|
||
const followMut = useMutation({
|
||
mutationFn: () =>
|
||
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["channel", id] });
|
||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||
},
|
||
});
|
||
|
||
const scheduleRefetch = (delayMs) => {
|
||
setIndexing(true);
|
||
setTimeout(() => {
|
||
qc.invalidateQueries({ queryKey: ["channel-videos", id] });
|
||
qc.invalidateQueries({ queryKey: ["channel", id] });
|
||
setIndexing(false);
|
||
}, delayMs);
|
||
};
|
||
|
||
const indexMut = useMutation({
|
||
mutationFn: () => indexChannel(id),
|
||
onSuccess: () => scheduleRefetch(6000),
|
||
});
|
||
|
||
const fullIndexMut = useMutation({
|
||
mutationFn: () => indexChannelFull(id),
|
||
onSuccess: () => scheduleRefetch(45000),
|
||
});
|
||
|
||
const exploreMut = useMutation({
|
||
mutationFn: () => exploreChannelOlder(id, explorePage),
|
||
onSuccess: () => {
|
||
setExplorePage(p => p + 1);
|
||
scheduleRefetch(20000);
|
||
},
|
||
});
|
||
|
||
const popularMut = useMutation({
|
||
mutationFn: () => fetchPopularVideos(id),
|
||
onSuccess: () => scheduleRefetch(20000),
|
||
});
|
||
|
||
const deepSearchMut = useMutation({
|
||
mutationFn: () => searchChannelYoutube(id, activeQ || search),
|
||
onSuccess: () => scheduleRefetch(20000),
|
||
});
|
||
|
||
const { data: playlists = [], refetch: refetchPlaylists } = useQuery({
|
||
queryKey: ["channel-playlists", id],
|
||
queryFn: () => getChannelPlaylists(id).then((r) => r.data),
|
||
enabled: !!id && tab === "playlists",
|
||
});
|
||
|
||
const fetchPlaylistsMut = useMutation({
|
||
mutationFn: () => fetchChannelPlaylists(id),
|
||
onSuccess: () => setTimeout(() => refetchPlaylists(), 8000),
|
||
});
|
||
|
||
const { data: playlistVideos, isLoading: loadingPlaylistVideos, refetch: refetchPlaylistVideos } = useQuery({
|
||
queryKey: ["playlist-videos", openPlaylistId, playlistOffset],
|
||
queryFn: () => getPlaylistVideos(openPlaylistId, playlistOffset, 60).then((r) => r.data),
|
||
enabled: !!openPlaylistId,
|
||
});
|
||
|
||
const indexPlaylistMut = useMutation({
|
||
mutationFn: (plId) => indexPlaylist(plId),
|
||
onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000),
|
||
});
|
||
|
||
const [dlResult, setDlResult] = useState(null);
|
||
const dlMut = useMutation({
|
||
mutationFn: () => downloadChannel(id),
|
||
onSuccess: (res) => {
|
||
setDlResult(res.data.queued);
|
||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||
},
|
||
});
|
||
|
||
const handleSearch = (e) => {
|
||
e.preventDefault();
|
||
setActiveQ(search.trim());
|
||
};
|
||
|
||
const clearSearch = () => {
|
||
setSearch("");
|
||
setActiveQ("");
|
||
searchInputRef.current?.focus();
|
||
};
|
||
|
||
if (loadingChannel) {
|
||
return (
|
||
<div className="flex items-center justify-center py-16">
|
||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
|
||
|
||
const isFollowed = channel.status === "followed";
|
||
const isPending = indexMut.isPending || fullIndexMut.isPending || deepSearchMut.isPending || exploreMut.isPending || popularMut.isPending || indexing;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5">
|
||
{/* Banner */}
|
||
<div className={`-mx-4 -mt-6 relative ${channel.banner_url ? "" : "bg-zinc-900"} rounded-b-2xl overflow-hidden`}>
|
||
{channel.banner_url && (
|
||
<img src={channel.banner_url} alt="" className="w-full h-28 sm:h-48 object-cover" />
|
||
)}
|
||
<div className={channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""} />
|
||
|
||
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-3`}>
|
||
{channel.thumbnail_url ? (
|
||
<img src={channel.thumbnail_url} alt={channel.name}
|
||
className="w-14 h-14 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40" />
|
||
) : (
|
||
<div className="w-14 h-14 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
|
||
{channel.name?.[0]?.toUpperCase()}
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<h1 className="font-display font-bold text-lg sm:text-2xl text-white drop-shadow leading-tight">{channel.name}</h1>
|
||
<p className="text-xs text-zinc-400 mt-0.5 drop-shadow">
|
||
{[
|
||
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subs`,
|
||
channel.video_count && `${channel.video_count} indexed`,
|
||
].filter(Boolean).join(" · ")}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="hidden sm:flex items-center gap-2 shrink-0">
|
||
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
|
||
className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}>
|
||
{isFollowed ? "Following" : "Follow"}
|
||
</button>
|
||
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
|
||
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
|
||
{dlMut.isPending ? "Queuing…" : "Download all"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile actions */}
|
||
<div className="sm:hidden flex items-center gap-2 -mt-1">
|
||
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
|
||
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors">
|
||
{isFollowed ? "Following ✓" : "Follow"}
|
||
</button>
|
||
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
|
||
className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
|
||
{dlMut.isPending ? "Queuing…" : "Download all"}
|
||
</button>
|
||
</div>
|
||
|
||
{dlResult != null && (
|
||
<p className="text-xs text-zinc-400 font-mono -mt-2">
|
||
{dlResult === 0 ? "Already up to date" : `${dlResult} downloads queued`}
|
||
</p>
|
||
)}
|
||
|
||
{channel.description && (
|
||
<p className="text-xs text-zinc-500 line-clamp-2 -mt-1">{channel.description}</p>
|
||
)}
|
||
|
||
{/* Search bar */}
|
||
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search videos…"
|
||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600 transition-colors"
|
||
/>
|
||
{search && (
|
||
<button type="button" onClick={clearSearch}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400">
|
||
<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>
|
||
<button type="submit"
|
||
className="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors shrink-0">
|
||
Filter
|
||
</button>
|
||
{activeQ && (
|
||
<button
|
||
type="button"
|
||
onClick={() => deepSearchMut.mutate()}
|
||
disabled={deepSearchMut.isPending || indexing}
|
||
className="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 transition-colors shrink-0 disabled:opacity-40"
|
||
>
|
||
Search YouTube
|
||
</button>
|
||
)}
|
||
</form>
|
||
|
||
{/* Tabs + controls */}
|
||
<div className="flex items-center justify-between gap-3 -mt-1 border-b border-zinc-800/60 pb-3">
|
||
<div className="flex items-center gap-0.5">
|
||
{TABS.map(t => (
|
||
<button key={t.value} onClick={() => setTab(t.value)}
|
||
className={`text-sm px-3 py-1 rounded-md transition-colors font-medium ${
|
||
tab === t.value ? "text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
|
||
}`}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{isPending && (
|
||
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
|
||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||
</svg>
|
||
Fetching…
|
||
</span>
|
||
)}
|
||
{tab === "popular" ? (
|
||
<button onClick={() => popularMut.mutate()} disabled={isPending}
|
||
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
|
||
Fetch popular
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button onClick={() => indexMut.mutate()} disabled={isPending}
|
||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
|
||
Fetch recent
|
||
</button>
|
||
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
|
||
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
|
||
Fetch all
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sort bar — videos tab only */}
|
||
{tab === "videos" && (
|
||
<div className="flex items-center gap-0.5 -mt-1">
|
||
{SORTS.map(s => (
|
||
<button key={s.value} onClick={() => setSort(s.value)}
|
||
className={`text-xs px-2.5 py-1 rounded-md transition-colors ${
|
||
sort === s.value ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
|
||
}`}>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Playlist browser */}
|
||
{tab === "playlists" && (
|
||
openPlaylistId ? (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={() => { setOpenPlaylistId(null); setPlaylistOffset(0); }}
|
||
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors">
|
||
← Back to playlists
|
||
</button>
|
||
{(() => {
|
||
const pl = playlists.find(p => p.id === openPlaylistId);
|
||
return pl ? <span className="text-sm font-medium text-zinc-200 truncate">{pl.title}</span> : null;
|
||
})()}
|
||
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
|
||
disabled={indexPlaylistMut.isPending}
|
||
className="ml-auto text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
|
||
{indexPlaylistMut.isPending ? "Indexing…" : "Re-index"}
|
||
</button>
|
||
</div>
|
||
{loadingPlaylistVideos ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
) : playlistVideos?.length ? (
|
||
<>
|
||
<div className="flex flex-col gap-1">
|
||
{playlistVideos.map((v) => (
|
||
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||
))}
|
||
</div>
|
||
<div className="flex items-center justify-center gap-4 mt-2">
|
||
{playlistOffset > 0 && (
|
||
<button onClick={() => setPlaylistOffset(o => Math.max(0, o - 60))}
|
||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||
Previous
|
||
</button>
|
||
)}
|
||
{playlistVideos.length === 60 && (
|
||
<button onClick={() => setPlaylistOffset(o => o + 60)}
|
||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||
Next
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="py-8 flex flex-col items-center gap-3">
|
||
<p className="text-zinc-500 text-sm">No videos indexed for this playlist yet.</p>
|
||
<button onClick={() => indexPlaylistMut.mutate(openPlaylistId)}
|
||
disabled={indexPlaylistMut.isPending}
|
||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||
{indexPlaylistMut.isPending ? "Indexing…" : "Index playlist"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-zinc-500">{playlists.length} playlists</span>
|
||
<button onClick={() => fetchPlaylistsMut.mutate()}
|
||
disabled={fetchPlaylistsMut.isPending}
|
||
className="text-xs text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40">
|
||
{fetchPlaylistsMut.isPending ? "Fetching…" : "Fetch playlists"}
|
||
</button>
|
||
</div>
|
||
{playlists.length === 0 ? (
|
||
<div className="py-8 flex flex-col items-center gap-3">
|
||
<p className="text-zinc-500 text-sm">No playlists fetched yet.</p>
|
||
<button onClick={() => fetchPlaylistsMut.mutate()}
|
||
disabled={fetchPlaylistsMut.isPending}
|
||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||
Fetch playlists from YouTube
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||
{playlists.map(pl => (
|
||
<button key={pl.id} onClick={() => { setOpenPlaylistId(pl.id); setPlaylistOffset(0); }}
|
||
className="text-left flex flex-col gap-1.5 rounded-lg overflow-hidden hover:bg-zinc-800/50 transition-colors p-1.5">
|
||
<div className="relative aspect-video w-full rounded overflow-hidden bg-zinc-800">
|
||
{pl.thumbnail_url ? (
|
||
<img src={pl.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center text-zinc-600">
|
||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||
d="M4 6h16M4 10h16M4 14h8M4 18h8" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||
{pl.video_count}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs font-medium text-zinc-200 line-clamp-2 leading-snug px-0.5">{pl.title}</p>
|
||
{pl.indexed_at && (
|
||
<p className="text-xs text-zinc-600 px-0.5">Indexed</p>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* Video list */}
|
||
{tab !== "playlists" && (loadingVideos ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
) : videos.length ? (
|
||
<div className="flex flex-col gap-1">
|
||
{videos.map((v) => (
|
||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
|
||
))}
|
||
|
||
{hasNextPage ? (
|
||
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}
|
||
className="mt-4 self-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40 py-2 px-4">
|
||
{isFetchingNextPage ? "Loading…" : "Load more"}
|
||
</button>
|
||
) : !activeQ && tab === "videos" && (
|
||
<div className="mt-4 flex flex-col items-center gap-3 py-4 border-t border-zinc-800/50">
|
||
<p className="text-xs text-zinc-600">{videos.length} videos indexed</p>
|
||
<div className="flex items-center gap-4">
|
||
<button onClick={() => exploreMut.mutate()} disabled={isPending}
|
||
className="text-xs text-zinc-400 hover:text-zinc-100 font-medium transition-colors disabled:opacity-40">
|
||
Explore older videos
|
||
</button>
|
||
<span className="text-zinc-800 text-xs">·</span>
|
||
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
|
||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-40">
|
||
Fetch entire history
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="py-8 flex flex-col items-center gap-3">
|
||
<p className="text-zinc-500 text-sm">
|
||
{activeQ
|
||
? `No indexed videos match "${activeQ}"`
|
||
: tab === "popular"
|
||
? "No popular videos fetched yet."
|
||
: "No videos indexed yet."}
|
||
</p>
|
||
{activeQ ? (
|
||
<button onClick={() => deepSearchMut.mutate()} disabled={isPending}
|
||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||
Search YouTube for "{activeQ}"
|
||
</button>
|
||
) : tab === "popular" ? (
|
||
<button onClick={() => popularMut.mutate()} disabled={isPending}
|
||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||
Fetch popular videos from YouTube
|
||
</button>
|
||
) : (
|
||
<button onClick={() => fullIndexMut.mutate()} disabled={isPending}
|
||
className="text-sm text-zinc-400 hover:text-zinc-100 transition-colors disabled:opacity-40 underline">
|
||
Fetch all videos
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|