Files
youclonedl/frontend/src/pages/Channel.jsx
Mattias Tall ebd8ddee6e Fix channel page mobile layout
On mobile: move action buttons below the banner into their own row
(flex-1 Follow + Download, compact Re-index) instead of cramming
three full-size buttons inside the banner overlay alongside the avatar
and name. Desktop keeps the original inline layout. Also reduced
banner height, avatar size, and description to 2-line clamp on mobile.

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

175 lines
7.8 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-6">
{/* 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" : ""} />
{/* Avatar + name — always in the banner overlay */}
<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} videos`,
].filter(Boolean).join(" · ")}
</p>
</div>
{/* Desktop action buttons inline */}
<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-yellow-300 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
<button onClick={() => indexMut.mutate()} disabled={indexMut.isPending || indexMut.isSuccess}
className="text-sm font-medium px-4 py-1.5 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>
</div>
</div>
</div>
{/* Mobile action row — below banner */}
<div className="sm:hidden flex items-center gap-2 -mt-2">
<button onClick={() => followMut.mutate()} disabled={followMut.isPending}
className={`flex-1 text-sm font-medium py-2 rounded-lg transition-colors ${isFollowed ? "bg-zinc-800 text-zinc-300 hover:bg-zinc-700" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"}`}>
{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-yellow-300 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
<button onClick={() => indexMut.mutate()} disabled={indexMut.isPending || indexMut.isSuccess}
className="text-sm font-medium px-3 py-2 rounded-lg bg-zinc-800 text-zinc-500 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{indexMut.isPending ? "…" : indexMut.isSuccess ? "✓" : "Re-index"}
</button>
</div>
{dlResult != null && (
<p className="text-xs text-accent font-mono -mt-2">
{dlResult === 0 ? "Already up to date" : `${dlResult} downloads queued`}
</p>
)}
{/* Description */}
{channel.description && (
<p className="text-xs text-zinc-500 line-clamp-2 -mt-2">{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="flex flex-col gap-2">
{sortVideos(videos, videoSort).map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
))}
</div>
</>
) : (
<p className="text-zinc-500 text-sm">No videos indexed yet. Hit Re-index to fetch them.</p>
)}
</div>
);
}