From 481728b93d6a365b49fbdf241083984ef764d660 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 25 Jul 2026 16:56:29 +0700 Subject: [PATCH] feat(downloads): let the download progress toast be minimized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
  • 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. --- .../[projectId]/project-content-client.tsx | 37 +++-- components/download-progress-toast.tsx | 146 ++++++++++++++++++ .../video-page/hooks/use-download-actions.ts | 27 +++- lib/client/download-file.ts | 11 +- 4 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 components/download-progress-toast.tsx diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx index 9180c4b..381d406 100644 --- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx +++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx @@ -51,6 +51,11 @@ import { runProjectDownloadManifest, type ProjectDownloadManifest, } from '@/lib/client/project-download'; +import { downloadProgressPercent } from '@/lib/client/download-file'; +import { + createDownloadProgressToast, + type DownloadProgressToastHandle, +} from '@/components/download-progress-toast'; interface SerializedVideo { id: string; @@ -203,6 +208,7 @@ export function ProjectContentClient({ const query = searchParams.toString() ? `?${searchParams.toString()}` : ''; setIsDownloading(true); + let progressToast: DownloadProgressToastHandle | null = null; try { const response = await fetch(`/api/projects/${projectId}/download${query}`, { cache: 'no-store', @@ -222,27 +228,26 @@ export function ProjectContentClient({ return; } - const downloadToastId = `project-download-${projectId}`; - toast.loading(`Downloading ${manifest.totalFiles} files…`, { - id: downloadToastId, - duration: Infinity, + progressToast = createDownloadProgressToast(`project-download-${projectId}`, { + title: `Downloading ${manifest.totalFiles} files`, + description: 'Starting…', }); await runProjectDownloadManifest(manifest, (p) => { - const pct = - p.totalBytes && p.totalBytes > 0 - ? ` · ${Math.min(100, Math.floor((p.receivedBytes / p.totalBytes) * 100))}%` - : ''; - toast.loading(`Downloading file ${p.index}/${p.total}`, { - id: downloadToastId, - description: `${p.fileName}${pct}`, - duration: Infinity, + const percent = downloadProgressPercent({ + receivedBytes: p.receivedBytes, + totalBytes: p.totalBytes, + }); + progressToast?.update({ + title: `Downloading file ${p.index}/${p.total}`, + description: `${p.fileName}${percent !== null ? ` · ${percent}%` : ''}`, + percent, }); }); - toast.success(`Downloaded ${manifest.totalFiles} files`, { - id: downloadToastId, - duration: 4000, - }); + progressToast.success(`Downloaded ${manifest.totalFiles} files`); } 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'); } finally { setIsDownloading(false); diff --git a/components/download-progress-toast.tsx b/components/download-progress-toast.tsx new file mode 100644 index 0000000..0449bb0 --- /dev/null +++ b/components/download-progress-toast.tsx @@ -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) => 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. +
    + {minimized ? ( + + ) : ( +
    +
    +
    + {!isSuccess && typeof state.percent === 'number' && ( + + )} +
    + )} +
    + ); +} + +/** + * 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( + () => ( + { + minimized = !minimized; + sessionMinimized = minimized; + render(); + }} + /> + ), + // The
  • 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); + }, + }; +} diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts index 13683a9..5ab8aba 100644 --- a/components/video-page/hooks/use-download-actions.ts +++ b/components/video-page/hooks/use-download-actions.ts @@ -13,9 +13,14 @@ import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { downloadNamedFile, downloadProgressLabel, + downloadProgressPercent, extensionFromUrl, navigateDownload, } from '@/lib/client/download-file'; +import { + createDownloadProgressToast, + type DownloadProgressToastHandle, +} from '@/components/download-progress-toast'; function sanitizeDownloadFileName(value: string): string { return value @@ -84,6 +89,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct'; setActiveDownloadTarget(target); + let progressToast: DownloadProgressToastHandle | null = null; try { 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 // a big file / slow connection takes a while with no native download UI - // — show live progress so it doesn't look stuck. - const toastId = `download-${activeVersion.id}`; - toast.loading(`Downloading “${baseName}”…`, { id: toastId, duration: Infinity }); + // — show live progress so it doesn't look stuck. The panel can be + // minimized because it sits over the comment composer. + progressToast = createDownloadProgressToast(`download-${activeVersion.id}`, { + title: `Downloading “${baseName}”`, + description: 'Starting…', + }); const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => { - toast.loading(`Downloading “${baseName}”`, { - id: toastId, + progressToast?.update({ description: downloadProgressLabel(p), - duration: Infinity, + percent: downloadProgressPercent(p), }); }); if (saved) { - toast.success(`“${baseName}” downloaded`, { id: toastId, duration: 4000 }); + progressToast.success(`“${baseName}” downloaded`); } else { // Too large to buffer (or fetch blocked): let the browser download it // directly (its own progress UI, CDN filename). - toast.dismiss(toastId); + progressToast.dismiss(); navigateDownload(downloadUrl); } } } catch (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') { toast.error('This direct download host is not allowed'); } else if (error instanceof Error && error.message) { diff --git a/lib/client/download-file.ts b/lib/client/download-file.ts index 707a801..bd0c190 100644 --- a/lib/client/download-file.ts +++ b/lib/client/download-file.ts @@ -40,10 +40,17 @@ export function formatBytes(bytes: number): string { 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 { const { receivedBytes, totalBytes } = progress; - if (totalBytes && totalBytes > 0) { - const pct = Math.min(100, Math.floor((receivedBytes / totalBytes) * 100)); + const pct = downloadProgressPercent(progress); + if (pct !== null && totalBytes) { return `${pct}% · ${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`; } return formatBytes(receivedBytes);