mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #37 from yusufipk/worktree-fix-download-notice
feat(downloads): let the download progress toast be minimized
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user