Files
youclonedl/frontend/src/pages/Discovery.jsx
Mattias Tall cb05b739a8 Mobile UX: fixed bottom nav, compact header, less cramped cards
- App-shell layout (height:100dvh, only main scrolls) so the bottom nav
  is a natural flex child and never disappears regardless of browser
  chrome show/hide behaviour
- Bottom nav reduced from h-16 to h-14, icons from 20px to 18px, labels
  from 10px to 9px — slimmer bar, still readable
- Header: min-w-0 on search prevents horizontal overflow; user/sign-out
  hidden on mobile (accessible via Settings); logo shortened to "YT" on
  mobile; px-3 / h-12 on mobile instead of px-4 / h-14
- Grid card descriptions hidden on mobile (hidden sm:block) — reduces
  height cramping in the 2-column feed
- scrollToTop() utility replaces window.scrollTo so pagination still
  scrolls to top within the new scroll container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:30:23 +02:00

358 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-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); scrollToTop(); }}
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); scrollToTop(); }}
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); scrollToTop(); }}
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); scrollToTop(); }}
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>
);
}