Files
youclonedl/frontend/src/pages/Downloads.jsx
Mattias Tall 94f74215e2 Consistency sweep: fix switcher layout, gaps, buttons, empty states
- Home: mode switcher moved to its own row (no longer crammed next to
  title on mobile), hide-watched simplified to text-only toggle
- Home/History/Discovery: pagination buttons text-sm → text-xs, page
  counter text-sm → text-xs
- Liked/Downloads/SearchResults: top-level gap-8 → gap-6
- Liked: refresh button px-4 py-2 text-sm → px-3 py-1.5 text-xs
- Empty states: standardize to text-zinc-500 text-sm across Queue,
  ContinueWatching, History, Following, Discovery, Liked
- Following: "Latest uploads" tab label → "Feed"
- Home: remove -mt-3 hacks from mode description rows

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

312 lines
11 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 { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload } from "../api";
import SortPicker from "../components/SortPicker";
const HISTORY_SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "Title AZ" },
{ value: "status", label: "Status" },
];
function sortHistory(items, sort) {
const arr = [...items];
if (sort === "oldest") return arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
if (sort === "title") return arr.sort((a, b) => (a.video_title ?? "").localeCompare(b.video_title ?? ""));
if (sort === "status") return arr.sort((a, b) => a.status.localeCompare(b.status));
return arr.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
const STATUS_COLORS = {
pending: "text-zinc-400",
downloading: "text-accent",
complete: "text-green-400",
failed: "text-red-400",
};
const STATUS_LABELS = {
pending: "Pending",
downloading: "Downloading",
complete: "Complete",
failed: "Failed",
};
function ProgressBar({ pct }) {
return (
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden mt-1.5">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
);
}
function daysRemaining(pendingDeleteAt) {
const diff = new Date(pendingDeleteAt) - Date.now();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
return Math.max(0, days);
}
export default function DownloadsPage() {
const [historySort, setHistorySort] = useState("newest");
const [confirmClear, setConfirmClear] = useState(false);
const qc = useQueryClient();
const { data: downloads, isLoading } = useQuery({
queryKey: ["downloads"],
queryFn: () => getDownloads().then((r) => r.data),
refetchInterval: (query) => {
const active = query.state.data?.some(
(d) => d.status === "pending" || d.status === "downloading"
);
return active ? 2000 : false;
},
});
const clearAllMut = useMutation({
mutationFn: deleteAllDownloads,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
setConfirmClear(false);
},
});
if (isLoading) {
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>
);
}
const active = downloads?.filter((d) => ["pending", "downloading"].includes(d.status)) ?? [];
const trash = downloads?.filter((d) => d.pending_delete_at) ?? [];
const history = useMemo(
() => sortHistory(
downloads?.filter((d) => !["pending", "downloading"].includes(d.status) && !d.pending_delete_at) ?? [],
historySort,
),
[downloads, historySort],
);
const hasRemovable = history.length > 0 || trash.length > 0;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
{hasRemovable && (
confirmClear ? (
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-400">Remove all history and trash?</span>
<button
onClick={() => clearAllMut.mutate()}
disabled={clearAllMut.isPending}
className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-500 disabled:opacity-50 transition-colors"
>
Yes, remove all
</button>
<button
onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmClear(true)}
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
>
Remove all
</button>
)
)}
</div>
{active.length > 0 && (
<section>
<h2 className="font-display font-semibold text-base text-zinc-400 mb-3">Active</h2>
<div className="flex flex-col gap-3">
{active.map((d) => (
<DownloadRow key={d.id} download={d} />
))}
</div>
</section>
)}
{trash.length > 0 && (
<section>
<h2 className="font-display font-semibold text-base text-zinc-400 mb-1">Trash</h2>
<p className="text-xs text-zinc-600 mb-3">Watched downloads auto-deleted when the timer runs out.</p>
<div className="flex flex-col gap-2">
{trash.map((d) => (
<TrashRow key={d.id} download={d} />
))}
</div>
</section>
)}
{history.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display font-semibold text-base text-zinc-400">History</h2>
<SortPicker value={historySort} onChange={setHistorySort} options={HISTORY_SORTS} />
</div>
<div className="flex flex-col gap-2">
{history.map((d) => (
<DownloadRow key={d.id} download={d} />
))}
</div>
</section>
)}
{!downloads?.length && (
<p className="text-zinc-500 text-sm">No downloads yet. Find a video and hit Download.</p>
)}
</div>
);
}
function TrashRow({ download: d }) {
const navigate = useNavigate();
const qc = useQueryClient();
const restoreMut = useMutation({
mutationFn: () => restoreDownload(d.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads"] }),
});
const deleteMut = useMutation({
mutationFn: () => deleteDownload(d.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["video-play", d.youtube_video_id] });
},
});
const days = daysRemaining(d.pending_delete_at);
const urgentColor = days <= 1 ? "text-red-400" : days <= 3 ? "text-amber-400" : "text-zinc-500";
return (
<div className="bg-zinc-900/60 border border-zinc-800 rounded-xl p-4 flex items-start gap-4">
<div
className="cursor-pointer shrink-0"
onClick={() => navigate(`/watch/${d.youtube_video_id}`)}
>
{d.video_thumbnail_url ? (
<img src={d.video_thumbnail_url} alt="" className="w-20 h-11 object-cover rounded-lg opacity-60" />
) : (
<div className="w-20 h-11 rounded-lg bg-zinc-800" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className="text-sm font-medium text-zinc-400 truncate cursor-pointer hover:text-zinc-200 transition-colors"
onClick={() => navigate(`/watch/${d.youtube_video_id}`)}
>
{d.video_title}
</p>
<p className={`text-xs mt-1 ${urgentColor}`}>
{days === 0 ? "Deletes today" : `Deletes in ${days} day${days !== 1 ? "s" : ""}`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => restoreMut.mutate()}
disabled={restoreMut.isPending}
title="Keep this file"
className="px-2.5 py-1.5 rounded-lg text-xs font-medium text-zinc-300 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 transition-colors"
>
Keep
</button>
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
title="Delete now"
className="p-1.5 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-900/20 disabled:opacity-40 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
);
}
function DownloadRow({ download: d }) {
const navigate = useNavigate();
const qc = useQueryClient();
const deleteMut = useMutation({
mutationFn: () => deleteDownload(d.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["video-play", d.youtube_video_id] });
},
});
const isActive = d.status === "pending" || d.status === "downloading";
const canWatch = d.status === "complete" && d.youtube_video_id;
return (
<div className="bg-zinc-900 rounded-xl p-4 flex items-start gap-4">
<div
className={canWatch ? "cursor-pointer shrink-0" : "shrink-0"}
onClick={canWatch ? () => navigate(`/watch/${d.youtube_video_id}`) : undefined}
>
{d.video_thumbnail_url ? (
<img
src={d.video_thumbnail_url}
alt=""
className="w-20 h-11 object-cover rounded-lg"
/>
) : (
<div className="w-20 h-11 rounded-lg bg-zinc-800" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium text-zinc-100 truncate ${canWatch ? "cursor-pointer hover:text-white" : ""}`}
onClick={canWatch ? () => navigate(`/watch/${d.youtube_video_id}`) : undefined}
>
{d.video_title}
</p>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs font-medium ${STATUS_COLORS[d.status]}`}>
{STATUS_LABELS[d.status]}
</span>
{d.status === "downloading" && (
<span className="text-xs text-zinc-500">{d.progress_percent.toFixed(0)}%</span>
)}
</div>
{d.status === "downloading" && <ProgressBar pct={d.progress_percent} />}
{d.error_message && (
<p className="text-xs text-red-400 mt-1 truncate">{d.error_message}</p>
)}
</div>
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
title={isActive ? "Cancel download" : "Delete file and record"}
className="shrink-0 p-1.5 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-900/20 transition-colors disabled:opacity-40"
>
{isActive ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</button>
</div>
);
}