Files
youclonedl/frontend/src/pages/Channel.jsx
Mattias Tall 219a388d72 Channel page: overlay avatar/name/buttons on banner
Name, subscriber count, and action buttons sit at the bottom of the
banner with a gradient overlay. Falls back to a plain dark header when
no banner is available. Description moves below the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:58:31 +02:00

179 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api";
import VideoCard from "../components/VideoCard";
import SortPicker from "../components/SortPicker";
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);
}
const VIDEO_SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "Title AZ" },
{ value: "unwatched", label: "Unwatched first" },
];
function sortVideos(items, sort) {
const arr = [...items];
if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0));
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
if (sort === "unwatched") return arr.sort((a, b) => Number(a.is_watched) - Number(b.is_watched));
return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
}
export default function ChannelPage() {
const { id } = useParams();
const qc = useQueryClient();
const { data: channel, isLoading: loadingChannel } = useQuery({
queryKey: ["channel", id],
queryFn: () => getChannel(id).then((r) => r.data),
});
const { data: videos, isLoading: loadingVideos } = useQuery({
queryKey: ["channel-videos", id],
queryFn: () => getChannelVideos(id).then((r) => r.data),
});
const followMut = useMutation({
mutationFn: () =>
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", id] });
qc.invalidateQueries({ queryKey: ["channels"] });
},
});
const indexMut = useMutation({
mutationFn: () => indexChannel(id),
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["channel-videos", id] }), 4000),
});
const [dlResult, setDlResult] = useState(null);
const [videoSort, setVideoSort] = useState("newest");
const dlMut = useMutation({
mutationFn: () => downloadChannel(id),
onSuccess: (res) => {
setDlResult(res.data.queued);
qc.invalidateQueries({ queryKey: ["downloads"] });
},
});
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";
return (
<div className="flex flex-col gap-8">
{/* Channel header — banner with overlay, or plain if no 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-36 sm:h-52 object-cover" />
)}
{/* Gradient overlay */}
<div className={`${channel.banner_url ? "absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" : ""}`} />
{/* Info row sits at the bottom of the banner */}
<div className={`${channel.banner_url ? "absolute bottom-0 inset-x-0" : ""} px-4 pb-4 pt-3 flex items-end gap-4`}>
{/* Avatar */}
{channel.thumbnail_url ? (
<img
src={channel.thumbnail_url}
alt={channel.name}
className="w-16 h-16 sm:w-20 sm:h-20 rounded-full object-cover shrink-0 ring-2 ring-black/40"
/>
) : (
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-zinc-800 flex items-center justify-center text-2xl sm:text-3xl font-display font-bold text-zinc-400 shrink-0">
{channel.name?.[0]?.toUpperCase()}
</div>
)}
{/* Name + meta */}
<div className="flex-1 min-w-0">
<h1 className="font-display font-bold text-xl sm:text-2xl text-white drop-shadow">{channel.name}</h1>
<p className="text-xs sm:text-sm text-zinc-300 mt-0.5 drop-shadow">
{[
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subscribers`,
`${channel.video_count} videos indexed`,
].filter(Boolean).join(" · ")}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{dlResult != null && (
<span className="text-sm text-accent font-mono">
{dlResult === 0 ? "Already up to date" : `${dlResult} queued`}
</span>
)}
<button
onClick={() => dlMut.mutate()}
disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-black hover:bg-yellow-300 transition-colors disabled:opacity-60 flex items-center gap-2"
>
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
<button
onClick={() => indexMut.mutate()}
disabled={indexMut.isPending || indexMut.isSuccess}
className="text-sm font-medium px-4 py-2 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{indexMut.isPending ? "Indexing…" : indexMut.isSuccess ? "Done ✓" : "Re-index"}
</button>
<button
onClick={() => followMut.mutate()}
disabled={followMut.isPending}
className={`text-sm font-medium px-4 py-2 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>
</div>
</div>
</div>
{/* Description below banner */}
{channel.description && (
<p className="text-sm text-zinc-400 line-clamp-3 -mt-4">{channel.description}</p>
)}
{/* Video grid */}
{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 justify-end">
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{sortVideos(videos, videoSort).map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} />
))}
</div>
</>
) : (
<p className="text-zinc-500 text-sm">No videos indexed yet. Hit Re-index to fetch them.</p>
)}
</div>
);
}