video download panel: grid layout — numbered playlist cells + large single card

Uses the resizable panel + container queries instead of a cramped vertical list:
- Single item → one large card (big 16:9 thumbnail, title, uploader·duration,
  full-width download button).
- Playlist → a responsive grid (2 cols, 3 at @520px, 4 at @760px of panel width)
  of compact cells, each with a position number badge and an overlay download/
  status chip.
Loading skeletons, error, and per-cell download states (download → downloading →
saving → saved / retry) carry over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:15:21 +00:00
co-authored by Claude Opus 4.8
parent 0a8ec845ef
commit 46b1d62138
@@ -36,6 +36,116 @@ const fmtDuration = (sec?: number): string => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
// The thumbnail contents for a card (loading skeleton spinner / error / cover art / audio icon).
const Thumb = ({ e, audioOnly, size }: { e: Entry; audioOnly: boolean; size: number }) => {
if (e.status === 'loading') return <Loader2 size={size} className="animate-spin text-duck-dark/40" />;
if (e.status === 'error') return <AlertCircle size={size} className="text-red-500/70" />;
if (e.thumbnail && !audioOnly) return <img src={e.thumbnail} alt="" className="h-full w-full object-cover" />;
return <Music size={size} className="text-duck-dark/40" />;
};
// Download control, in two flavors: `overlay` (a chip pinned in a grid thumbnail) or full-width (single).
const CardAction = ({ e, onDownload, overlay }: { e: Entry; onDownload: () => void; overlay?: boolean }) => {
if (e.dl === 'done')
return overlay ? (
<span className="absolute bottom-1 right-1 flex items-center gap-1 rounded bg-duck-teal px-1.5 py-0.5 text-[10px] font-medium text-duck-yellow">
<Check size={11} /> Saved
</span>
) : (
<div className="flex items-center justify-center gap-1.5 rounded-md bg-duck-teal/15 px-4 py-2 text-sm font-medium text-duck-teal">
<Check size={15} /> Saved
</div>
);
if (e.dl === 'downloading' || e.dl === 'saving') {
const label = e.dl === 'saving' ? 'Saving…' : 'Downloading…';
return overlay ? (
<span className="absolute bottom-1 right-1 flex items-center gap-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-white">
<Loader2 size={11} className="animate-spin" /> {label}
</span>
) : (
<div className="flex items-center justify-center gap-1.5 rounded-md bg-duck-dark/5 px-4 py-2 text-sm text-duck-dark/60">
<Loader2 size={15} className="animate-spin" /> {label}
</div>
);
}
const label = e.dl === 'error' ? 'Retry' : 'Download';
return overlay ? (
<button
type="button"
onClick={onDownload}
className="absolute bottom-1 right-1 flex cursor-pointer items-center gap-1 rounded bg-duck-teal px-2 py-1 text-[10px] font-medium text-duck-yellow shadow-sm transition-colors hover:bg-duck-teal/90"
>
<Download size={11} /> {label}
</button>
) : (
<button
type="button"
onClick={onDownload}
className="flex cursor-pointer items-center justify-center gap-1.5 rounded-md bg-duck-teal px-4 py-2.5 text-sm font-medium text-duck-yellow transition-colors hover:bg-duck-teal/90"
>
<Download size={15} /> {label}
</button>
);
};
const sub = (e: Entry) => [e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ');
// Compact grid cell (playlist), with a position number badge.
const GridCard = ({
e,
number,
audioOnly,
onDownload,
}: {
e: Entry;
number: number;
audioOnly: boolean;
onDownload: () => void;
}) => (
<div className="flex flex-col gap-1">
<div className="relative flex aspect-video items-center justify-center overflow-hidden rounded-md bg-duck-dark/10">
<Thumb e={e} audioOnly={audioOnly} size={18} />
<span className="absolute left-1 top-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-white">
{number}
</span>
{e.status === 'ready' && <CardAction e={e} onDownload={onDownload} overlay />}
</div>
{e.status !== 'loading' && (
<>
<p className="truncate text-xs font-medium text-duck-dark" title={e.title}>
{e.status === 'error' ? 'Could not fetch' : e.title || e.url}
</p>
<p className="truncate text-[10px] text-duck-dark/50">{e.status === 'error' ? e.error || '' : sub(e)}</p>
</>
)}
</div>
);
// Large single-item card.
const SingleCard = ({ e, audioOnly, onDownload }: { e: Entry; audioOnly: boolean; onDownload: () => void }) => (
<div className="flex flex-col gap-3">
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg bg-duck-dark/10">
<Thumb e={e} audioOnly={audioOnly} size={36} />
</div>
{e.status === 'error' ? (
<div>
<p className="font-medium text-red-500">Could not fetch</p>
<p className="break-all text-sm text-duck-dark/50">{e.error || e.url}</p>
</div>
) : e.status === 'ready' ? (
<>
<div>
<p className="text-base font-semibold text-duck-dark" title={e.title}>
{e.title || e.url}
</p>
{sub(e) && <p className="text-sm text-duck-dark/60">{sub(e)}</p>}
</div>
<CardAction e={e} onDownload={onDownload} />
</>
) : null}
</div>
);
export const VideoDownloadPanelHeader = () => (
<>
<Download className="h-3.5 w-3.5 shrink-0" />
@@ -145,9 +255,6 @@ export const VideoDownloadPanel = () => {
setDownloadingAll(false);
};
const dlLabel = (e: Entry) =>
e.dl === 'downloading' ? 'Downloading…' : e.dl === 'saving' ? 'Saving…' : e.dl === 'done' ? 'Saved' : '';
return (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
<div className="flex items-center gap-1.5 text-xs text-duck-dark/60">
@@ -219,72 +326,26 @@ export const VideoDownloadPanel = () => {
</div>
)}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{entries.map((e, i) => (
<div
key={`${e.url}-${i}`}
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
>
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
{e.status === 'loading' ? (
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
) : e.status === 'error' ? (
<AlertCircle size={16} className="text-red-500/70" />
) : e.thumbnail && !audioOnly ? (
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
) : (
<Music size={16} className="text-duck-dark/40" />
)}
</div>
<div className="min-w-0 flex-1">
{e.status === 'loading' ? (
<div className="space-y-1.5">
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
<>
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
{e.title || e.url}
</p>
<p className="truncate text-xs text-duck-dark/50">
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
</p>
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
</>
)}
</div>
{e.status === 'ready' && (
<div className="shrink-0">
{e.dl === 'done' ? (
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
<Check size={14} /> Saved
</span>
) : e.dl === 'downloading' || e.dl === 'saving' ? (
<span className="flex items-center gap-1.5 text-xs text-duck-dark/60">
<Loader2 size={13} className="animate-spin" /> {dlLabel(e)}
</span>
) : (
<button
type="button"
onClick={() => void downloadEntry(i, e.url)}
className="flex cursor-pointer items-center gap-1.5 rounded-md border border-duck-teal/40 px-2.5 py-1.5 text-xs font-medium text-duck-teal transition-colors hover:bg-duck-teal/10"
>
<Download size={13} />
{e.dl === 'error' ? 'Retry' : 'Download'}
</button>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{entries.length === 1 ? (
<SingleCard
e={entries[0]!}
audioOnly={audioOnly}
onDownload={() => void downloadEntry(0, entries[0]!.url)}
/>
) : (
<div className="grid grid-cols-2 gap-x-2 gap-y-3 @min-[520px]:grid-cols-3 @min-[760px]:grid-cols-4">
{entries.map((e, i) => (
<GridCard
key={`${e.url}-${i}`}
e={e}
number={i + 1}
audioOnly={audioOnly}
onDownload={() => void downloadEntry(i, e.url)}
/>
))}
</div>
))}
)}
</div>
</div>
)}