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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user