feat(downloads): let the download progress toast be minimized

The download progress toast sits in the bottom-right corner on top of the
comment composer, blocking the voice-recording button and the comment box for
the whole duration of a download.

Render it through toast.custom so it can be collapsed to a small pill (percent
+ spinner) and expanded again while the download keeps running. The minimized
choice sticks for the rest of the session. The sonner <li> is click-through, so
only the panel itself covers the controls underneath.

Also dismiss the panel on failure — it had duration: Infinity and used to stay
on screen forever after an error.
This commit is contained in:
yusufipk
2026-07-25 16:56:29 +07:00
parent a14eb9fb84
commit 481728b93d
4 changed files with 195 additions and 26 deletions
@@ -51,6 +51,11 @@ import {
runProjectDownloadManifest, runProjectDownloadManifest,
type ProjectDownloadManifest, type ProjectDownloadManifest,
} from '@/lib/client/project-download'; } from '@/lib/client/project-download';
import { downloadProgressPercent } from '@/lib/client/download-file';
import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
} from '@/components/download-progress-toast';
interface SerializedVideo { interface SerializedVideo {
id: string; id: string;
@@ -203,6 +208,7 @@ export function ProjectContentClient({
const query = searchParams.toString() ? `?${searchParams.toString()}` : ''; const query = searchParams.toString() ? `?${searchParams.toString()}` : '';
setIsDownloading(true); setIsDownloading(true);
let progressToast: DownloadProgressToastHandle | null = null;
try { try {
const response = await fetch(`/api/projects/${projectId}/download${query}`, { const response = await fetch(`/api/projects/${projectId}/download${query}`, {
cache: 'no-store', cache: 'no-store',
@@ -222,27 +228,26 @@ export function ProjectContentClient({
return; return;
} }
const downloadToastId = `project-download-${projectId}`; progressToast = createDownloadProgressToast(`project-download-${projectId}`, {
toast.loading(`Downloading ${manifest.totalFiles} files`, { title: `Downloading ${manifest.totalFiles} files`,
id: downloadToastId, description: 'Starting…',
duration: Infinity,
}); });
await runProjectDownloadManifest(manifest, (p) => { await runProjectDownloadManifest(manifest, (p) => {
const pct = const percent = downloadProgressPercent({
p.totalBytes && p.totalBytes > 0 receivedBytes: p.receivedBytes,
? ` · ${Math.min(100, Math.floor((p.receivedBytes / p.totalBytes) * 100))}%` totalBytes: p.totalBytes,
: ''; });
toast.loading(`Downloading file ${p.index}/${p.total}`, { progressToast?.update({
id: downloadToastId, title: `Downloading file ${p.index}/${p.total}`,
description: `${p.fileName}${pct}`, description: `${p.fileName}${percent !== null ? ` · ${percent}%` : ''}`,
duration: Infinity, percent,
}); });
}); });
toast.success(`Downloaded ${manifest.totalFiles} files`, { progressToast.success(`Downloaded ${manifest.totalFiles} files`);
id: downloadToastId,
duration: 4000,
});
} catch { } catch {
// The progress panel never expires on its own, so clear it before the
// error toast replaces it.
progressToast?.dismiss();
toast.error('Failed to start project download'); toast.error('Failed to start project download');
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
+146
View File
@@ -0,0 +1,146 @@
'use client';
import { Check, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
const SUCCESS_DURATION_MS = 4000;
export type DownloadToastState = {
title: string;
description?: string;
/** 0-100 when the total size is known, null while it is unknown. */
percent?: number | null;
status?: 'loading' | 'success';
};
export type DownloadProgressToastHandle = {
update: (next: Partial<DownloadToastState>) => void;
success: (title: string, description?: string) => void;
dismiss: () => void;
};
// Minimizing is a "get out of my way" choice, so it sticks for the rest of the
// session instead of every new download popping the panel open again.
let sessionMinimized = false;
function minimizedLabel(state: DownloadToastState): string {
if (state.status === 'success') return 'Done';
return typeof state.percent === 'number' ? `${Math.round(state.percent)}%` : 'Downloading';
}
function DownloadProgressToast({
state,
minimized,
onToggleMinimized,
}: {
state: DownloadToastState;
minimized: boolean;
onToggleMinimized: () => void;
}) {
const isSuccess = state.status === 'success';
const StatusIcon = isSuccess ? Check : Loader2;
return (
// The wrapper spans the toast column but stays click-through, so the
// controls underneath (comment composer, voice recording) keep working
// wherever the panel itself isn't.
<div className="pointer-events-none flex w-[var(--width)] max-w-[calc(100vw-2rem)] justify-end">
{minimized ? (
<button
type="button"
onClick={onToggleMinimized}
aria-label="Expand download progress"
className="pointer-events-auto flex items-center gap-1.5 rounded-full border bg-background/95 px-2.5 py-1 text-xs shadow-lg backdrop-blur transition-colors hover:bg-muted"
>
<StatusIcon
className={cn('h-3.5 w-3.5 shrink-0', !isSuccess && 'animate-spin')}
aria-hidden="true"
/>
<span className="tabular-nums">{minimizedLabel(state)}</span>
<ChevronUp className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
</button>
) : (
<div className="pointer-events-auto w-full rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur">
<div className="flex items-start gap-2">
<StatusIcon
className={cn('mt-0.5 h-4 w-4 shrink-0', !isSuccess && 'animate-spin')}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{state.title}</p>
{state.description && (
<p className="mt-0.5 truncate text-xs text-muted-foreground tabular-nums">
{state.description}
</p>
)}
</div>
<button
type="button"
onClick={onToggleMinimized}
aria-label="Minimize download progress"
className="-mt-1 -mr-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{!isSuccess && typeof state.percent === 'number' && (
<Progress value={state.percent} className="mt-2 h-1" />
)}
</div>
)}
</div>
);
}
/**
* Long downloads are streamed through the browser with no native progress UI,
* so we show our own — but it sits in the bottom-right corner, on top of the
* comment composer. This wraps the progress toast in a panel the user can
* collapse to a small pill (and expand again) while the download continues.
*/
export function createDownloadProgressToast(
id: string,
initial: DownloadToastState
): DownloadProgressToastHandle {
let state: DownloadToastState = { status: 'loading', percent: null, ...initial };
let minimized = sessionMinimized;
let duration: number = Number.POSITIVE_INFINITY;
const render = () => {
toast.custom(
() => (
<DownloadProgressToast
state={state}
minimized={minimized}
onToggleMinimized={() => {
minimized = !minimized;
sessionMinimized = minimized;
render();
}}
/>
),
// The <li> sonner wraps this in is as wide as the toast column even when
// only the pill is showing, so clicks pass through it too.
{ id, duration, className: 'pointer-events-none' }
);
};
render();
return {
update(next) {
state = { ...state, ...next };
render();
},
success(title, description) {
state = { ...state, title, description, percent: 100, status: 'success' };
duration = SUCCESS_DURATION_MS;
render();
},
dismiss() {
toast.dismiss(id);
},
};
}
@@ -13,9 +13,14 @@ import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { import {
downloadNamedFile, downloadNamedFile,
downloadProgressLabel, downloadProgressLabel,
downloadProgressPercent,
extensionFromUrl, extensionFromUrl,
navigateDownload, navigateDownload,
} from '@/lib/client/download-file'; } from '@/lib/client/download-file';
import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
} from '@/components/download-progress-toast';
function sanitizeDownloadFileName(value: string): string { function sanitizeDownloadFileName(value: string): string {
return value return value
@@ -84,6 +89,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct'; const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target); setActiveDownloadTarget(target);
let progressToast: DownloadProgressToastHandle | null = null;
try { try {
let downloadUrl: string | null = null; let downloadUrl: string | null = null;
@@ -151,27 +157,32 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
// The file is pulled into the browser before it can be saved, which on // The file is pulled into the browser before it can be saved, which on
// a big file / slow connection takes a while with no native download UI // a big file / slow connection takes a while with no native download UI
// — show live progress so it doesn't look stuck. // — show live progress so it doesn't look stuck. The panel can be
const toastId = `download-${activeVersion.id}`; // minimized because it sits over the comment composer.
toast.loading(`Downloading “${baseName}”…`, { id: toastId, duration: Infinity }); progressToast = createDownloadProgressToast(`download-${activeVersion.id}`, {
title: `Downloading “${baseName}`,
description: 'Starting…',
});
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => { const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => {
toast.loading(`Downloading “${baseName}`, { progressToast?.update({
id: toastId,
description: downloadProgressLabel(p), description: downloadProgressLabel(p),
duration: Infinity, percent: downloadProgressPercent(p),
}); });
}); });
if (saved) { if (saved) {
toast.success(`${baseName}” downloaded`, { id: toastId, duration: 4000 }); progressToast.success(`${baseName}” downloaded`);
} else { } else {
// Too large to buffer (or fetch blocked): let the browser download it // Too large to buffer (or fetch blocked): let the browser download it
// directly (its own progress UI, CDN filename). // directly (its own progress UI, CDN filename).
toast.dismiss(toastId); progressToast.dismiss();
navigateDownload(downloadUrl); navigateDownload(downloadUrl);
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to start video download:', error); console.error('Failed to start video download:', error);
// The progress panel never expires on its own, so clear it before the
// error toast replaces it.
progressToast?.dismiss();
if (error instanceof Error && error.message === 'Direct download URL is not allowed') { if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed'); toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) { } else if (error instanceof Error && error.message) {
+9 -2
View File
@@ -40,10 +40,17 @@ export function formatBytes(bytes: number): string {
return `${(mb / 1024).toFixed(2)} GB`; return `${(mb / 1024).toFixed(2)} GB`;
} }
/** Percentage done, or null when the server didn't send a Content-Length. */
export function downloadProgressPercent(progress: DownloadProgress): number | null {
const { receivedBytes, totalBytes } = progress;
if (!totalBytes || totalBytes <= 0) return null;
return Math.min(100, Math.floor((receivedBytes / totalBytes) * 100));
}
export function downloadProgressLabel(progress: DownloadProgress): string { export function downloadProgressLabel(progress: DownloadProgress): string {
const { receivedBytes, totalBytes } = progress; const { receivedBytes, totalBytes } = progress;
if (totalBytes && totalBytes > 0) { const pct = downloadProgressPercent(progress);
const pct = Math.min(100, Math.floor((receivedBytes / totalBytes) * 100)); if (pct !== null && totalBytes) {
return `${pct}% · ${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`; return `${pct}% · ${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`;
} }
return formatBytes(receivedBytes); return formatBytes(receivedBytes);