feat: add progress indication for long downloads

This commit is contained in:
Vasily Zubarev
2025-05-19 16:29:04 +02:00
parent d2ef3a088a
commit 90a2411960
10 changed files with 404 additions and 30 deletions

48
hooks/use-download.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useState } from "react"
interface UseDownloadOptions {
onSuccess?: () => void
onError?: (error: Error) => void
}
export function useDownload(options: UseDownloadOptions = {}) {
const [isDownloading, setIsDownloading] = useState(false)
const download = async (url: string, defaultName: string) => {
try {
setIsDownloading(true)
const response = await fetch(url)
if (!response.ok) throw new Error("Download failed")
// Get the filename from the Content-Disposition header
const contentDisposition = response.headers.get("Content-Disposition")
const filename = contentDisposition ? contentDisposition.split("filename=")[1].replace(/"/g, "") : defaultName
// Create a blob from the response
const blob = await response.blob()
// Create a download link and trigger it
const downloadLink = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadLink
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(downloadLink)
document.body.removeChild(a)
options.onSuccess?.()
} catch (error) {
console.error("Download error:", error)
options.onError?.(error instanceof Error ? error : new Error("Download failed"))
} finally {
setIsDownloading(false)
}
}
return {
download,
isDownloading,
}
}

85
hooks/use-progress.tsx Normal file
View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react"
interface Progress {
id: string
current: number
total: number
type: string
data: any
createdAt: string
}
interface UseProgressOptions {
onSuccess?: (progress: Progress) => void
onError?: (error: Error) => void
}
export function useProgress(options: UseProgressOptions = {}) {
const [isLoading, setIsLoading] = useState(false)
const [eventSource, setEventSource] = useState<EventSource | null>(null)
const [progress, setProgress] = useState<Progress | null>(null)
// Cleanup on unmount
useEffect(() => {
return () => {
if (eventSource) {
eventSource.close()
}
}
}, [eventSource])
const startProgress = async (type: string) => {
setIsLoading(true)
setProgress(null)
// Close any existing connection
if (eventSource) {
eventSource.close()
}
try {
const progressId = crypto.randomUUID()
const source = new EventSource(`/api/progress/${progressId}?type=${type}`)
setEventSource(source)
source.onmessage = (event) => {
try {
const progress = JSON.parse(event.data)
setProgress(progress)
options.onSuccess?.(progress)
if (progress.current === progress.total && progress.total > 0) {
source.close()
setIsLoading(false)
}
} catch (error) {
console.error("Failed to parse progress data:", error)
source.close()
setIsLoading(false)
}
}
source.onerror = (error) => {
source.close()
setIsLoading(false)
const err = new Error("Progress tracking failed")
console.error("Progress tracking error:", err)
options.onError?.(err)
}
return progressId
} catch (error) {
setIsLoading(false)
const err = error instanceof Error ? error : new Error("Failed to start progress")
console.error("Failed to start progress:", err)
options.onError?.(err)
return null
}
}
return {
isLoading,
startProgress,
progress,
}
}