Files
dataroom/components/datarooms/download-progress-modal.tsx
zeekayandHanzo Dev 6b68c9299e kv: one name for the store, and it is Hanzo KV
The client was already @hanzo/kv; only the vocabulary was still Redis. Every
module, symbol and comment now says what it connects to:

  lib/redis.ts                    -> lib/kv.ts                (exports kv, lockKv)
  lib/redis-job-store.ts          -> lib/kv-job-store.ts      (KvJobStore)
  lib/redis-download-job-store.ts -> lib/kv-download-job-store.ts
  lib/files/tus-redis-locker.ts   -> lib/files/tus-kv-locker.ts (KvLocker, KvLock)
  lib/api/domains/redis.ts        -> lib/api/domains/kv.ts    (getKey)
  lib/redis/                      -> lib/kv-queue/

KV_URL is now the only endpoint variable — the REDIS_URL fallback and the
redis://localhost default are gone, and the default scheme is kv://, which
@hanzo/kv parses natively (utils/index.js). .env.example gained the KV_URL it
never documented, and lost UPSTASH_REDIS_REST_LOCKER_URL/TOKEN: nothing has read
those since the client changed — the locker opens a second connection to the
same KV, so there is no second endpoint to configure.

The second connection is `lockKv`, not `locker`: both tus routes already have a
local `const locker = new KvLocker(...)`, so naming the client `locker` made the
lock take itself as its own client.

Verified against the repo's own gate (npx tsc --noEmit): 100 errors before and
after, 0 introduced, compared set-wise by file:line:code. Behaviour is untouched
— this is naming, one connection string, and dead config.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 10:09:34 -07:00

682 lines
24 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronUp,
Download,
FileArchive,
Loader2,
Plus,
XCircle,
} from "lucide-react";
import { DownloadJob } from "@/lib/kv-download-job-store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
export interface DownloadJobStatus {
id: string;
status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED";
progress: number;
totalFiles: number;
processedFiles: number;
downloadUrls?: string[];
error?: string;
isReady: boolean;
dataroomName: string;
createdAt: string;
completedAt?: string;
expiresAt?: string;
}
interface DownloadProgressModalProps {
isOpen: boolean;
onClose: () => void;
jobId: string | null;
dataroomName?: string;
// For team member downloads (uses next-auth session)
teamId: string;
dataroomId: string;
}
export function DownloadProgressModal({
isOpen,
onClose,
jobId,
dataroomName,
teamId,
dataroomId,
}: DownloadProgressModalProps) {
const [status, setStatus] = useState<DownloadJobStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [existingDownloads, setExistingDownloads] = useState<DownloadJob[]>([]);
const [loading, setLoading] = useState(true);
const [showNewDownload, setShowNewDownload] = useState(false);
const [isStartingDownload, setIsStartingDownload] = useState(false);
const [expandedDownloadId, setExpandedDownloadId] = useState<string | null>(
null,
);
const [downloadProgress, setDownloadProgress] = useState<{
downloadId: string;
current: number;
total: number;
} | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup interval on component unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// Fetch existing downloads when modal opens
useEffect(() => {
if (!isOpen || !teamId || !dataroomId) return;
const fetchExistingDownloads = async () => {
try {
setLoading(true);
const endpoint = `/api/teams/${teamId}/datarooms/${dataroomId}/download/jobs`;
const response = await fetch(endpoint, {
method: "GET",
credentials: "include",
});
if (response.ok) {
const downloads = await response.json();
setExistingDownloads(downloads);
// If we have a current jobId, show the new download view
if (jobId) {
setShowNewDownload(true);
}
} else {
console.error("Failed to fetch existing downloads");
}
} catch (error) {
console.error("Error fetching existing downloads:", error);
} finally {
setLoading(false);
}
};
fetchExistingDownloads();
}, [isOpen, dataroomId, teamId, jobId]);
const fetchStatus = useCallback(
async (statusJobId: string) => {
if (!statusJobId || !teamId || !dataroomId) return;
try {
const url = `/api/teams/${teamId}/datarooms/${dataroomId}/download/${statusJobId}`;
const response = await fetch(url, {
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to fetch download status");
}
const data = await response.json();
setStatus(data);
setError(null);
// Stop polling when job is completed or failed
if (data.status === "COMPLETED" || data.status === "FAILED") {
setIsPolling(false);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setIsPolling(false);
}
},
[teamId, dataroomId],
);
// Start polling when we have a jobId and showNewDownload is true
useEffect(() => {
if (isOpen && jobId && showNewDownload) {
setIsPolling(true);
setStatus(null);
setError(null);
fetchStatus(jobId);
// Start polling interval
pollIntervalRef.current = setInterval(() => fetchStatus(jobId), 2000);
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
}, [isOpen, jobId, showNewDownload, fetchStatus]);
const startNewDownload = async () => {
if (!teamId || !dataroomId) {
setError("Missing required parameters to start download");
return;
}
setIsStartingDownload(true);
setShowNewDownload(true);
try {
const endpoint = `/api/teams/${teamId}/datarooms/${dataroomId}/download/bulk`;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to start download");
}
if (data.jobId) {
// Start polling for this job
setStatus({
id: data.jobId,
status: data.status || "PENDING",
progress: 0,
totalFiles: 0,
processedFiles: 0,
isReady: false,
dataroomName: dataroomName || "",
createdAt: new Date().toISOString(),
});
setIsPolling(true);
const statusUrl = `/api/teams/${teamId}/datarooms/${dataroomId}/download/${data.jobId}`;
pollIntervalRef.current = setInterval(async () => {
const statusResponse = await fetch(statusUrl, {
credentials: "include",
});
if (statusResponse.ok) {
const statusData = await statusResponse.json();
setStatus(statusData);
if (
statusData.status === "COMPLETED" ||
statusData.status === "FAILED"
) {
setIsPolling(false);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
}
}, 2000);
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
setShowNewDownload(false);
} finally {
setIsStartingDownload(false);
}
};
const handleDownload = (url: string) => {
const link = document.createElement("a");
link.href = url;
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
setTimeout(() => {
document.body.removeChild(link);
}, 100);
};
const handleDownloadAll = async (downloadId: string, urls: string[]) => {
if (downloadProgress) return;
setDownloadProgress({ downloadId, current: 0, total: urls.length });
for (let i = 0; i < urls.length; i++) {
setDownloadProgress({ downloadId, current: i + 1, total: urls.length });
handleDownload(urls[i]);
if (i < urls.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
setDownloadProgress(null);
};
const handleClose = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setStatus(null);
setError(null);
setIsPolling(false);
setShowNewDownload(false);
setExistingDownloads([]);
setExpandedDownloadId(null);
setLoading(true);
onClose();
};
const getStatusIcon = (jobStatus?: string) => {
switch (jobStatus) {
case "PENDING":
return (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
case "PROCESSING":
return <FileArchive className="h-4 w-4 animate-pulse text-primary" />;
case "COMPLETED":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "FAILED":
return <XCircle className="h-4 w-4 text-destructive" />;
default:
return (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
}
};
const getStatusColor = (jobStatus: string) => {
switch (jobStatus) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatExpirationTime = (expiresAt?: string) => {
if (!expiresAt) return null;
const expires = new Date(expiresAt);
const now = new Date();
const diffMs = expires.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays} day${diffDays > 1 ? "s" : ""}`;
} else if (diffHours > 0) {
return `${diffHours} hour${diffHours > 1 ? "s" : ""}`;
}
return "less than an hour";
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Download {dataroomName || status?.dataroomName || "Dataroom"}
</DialogTitle>
<DialogDescription>
{showNewDownload
? status?.status === "COMPLETED"
? "Your files are ready to download."
: "Please wait while we prepare your files..."
: "View previous downloads or start a new one."}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : showNewDownload ? (
// Show download progress
<div className="flex flex-col items-center space-y-4 py-6">
{/* Status Icon */}
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
{status?.status === "COMPLETED" ? (
<CheckCircle2 className="h-8 w-8 text-green-500" />
) : status?.status === "FAILED" ? (
<XCircle className="h-8 w-8 text-destructive" />
) : (
<FileArchive className="h-8 w-8 animate-pulse text-primary" />
)}
</div>
{/* Status Message */}
<p
className={cn(
"text-center text-sm",
status?.status === "FAILED"
? "text-destructive"
: "text-muted-foreground",
)}
>
{!status
? "Starting download..."
: status.status === "PENDING"
? "Preparing your download..."
: status.status === "PROCESSING"
? `Processing ${status.processedFiles} of ${status.totalFiles} files...`
: status.status === "COMPLETED"
? status.downloadUrls && status.downloadUrls.length > 1
? `Your download is ready! ${status.downloadUrls.length} ZIP files have been created.`
: "Your download is ready!"
: status.error || "Download failed. Please try again."}
</p>
{/* Progress Bar */}
{(status?.status === "PROCESSING" ||
status?.status === "PENDING") && (
<div className="w-full space-y-2">
<Progress
value={status?.progress || 0}
text={`${status?.progress || 0}%`}
className="h-4"
/>
<p className="text-center text-xs text-muted-foreground">
{status?.totalFiles
? `${status.processedFiles || 0} / ${status.totalFiles} files`
: "Calculating..."}
</p>
</div>
)}
{/* Download Links */}
{status?.status === "COMPLETED" && status.downloadUrls && (
<div className="w-full space-y-3">
{status.downloadUrls.length === 1 ? (
<Button
className="w-full"
onClick={() => handleDownload(status.downloadUrls![0])}
>
<Download className="mr-2 h-4 w-4" />
Download ZIP
</Button>
) : (
<>
<Button
className="w-full"
disabled={!!downloadProgress}
onClick={() =>
handleDownloadAll(status.id, status.downloadUrls!)
}
>
{downloadProgress?.downloadId === status.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Downloading {downloadProgress.current} of{" "}
{downloadProgress.total}...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download All ({status.downloadUrls.length} parts)
</>
)}
</Button>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Or download individually:
</p>
<div className="max-h-48 space-y-1 overflow-y-auto">
{status.downloadUrls.map((url, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload(url)}
>
<FileArchive className="mr-2 h-3 w-3" />
Part {index + 1} of {status.downloadUrls!.length}
</Button>
))}
</div>
</div>
</>
)}
{/* Expiration Notice */}
{status.expiresAt && (
<div className="flex items-center justify-center gap-1 text-xs text-muted-foreground">
<AlertCircle className="h-3 w-3" />
<span>
Download expires in{" "}
{formatExpirationTime(status.expiresAt)}
</span>
</div>
)}
</div>
)}
{/* Error State */}
{status?.status === "FAILED" && (
<Button
variant="outline"
onClick={() => setShowNewDownload(false)}
>
Back to Downloads
</Button>
)}
{/* Back button for processing state */}
{(status?.status === "PROCESSING" ||
status?.status === "PENDING") && (
<DialogFooter className="w-full sm:justify-center">
<p className="text-xs text-muted-foreground">
You can close this dialog. We&apos;ll notify you when your
download is ready.
</p>
</DialogFooter>
)}
{/* Loading Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
</div>
) : (
// Show existing downloads and new download option
<div className="space-y-4 py-2">
{existingDownloads.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium">Recent Downloads</h4>
<div className="max-h-64 space-y-2 overflow-y-auto">
{existingDownloads.map((download) => (
<div key={download.id} className="space-y-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
{getStatusIcon(download.status)}
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${getStatusColor(download.status)}`}
>
{download.status}
</span>
</div>
<div className="text-xs text-muted-foreground">
{formatDate(download.createdAt)}
</div>
{download.totalFiles > 0 && (
<div className="text-xs text-muted-foreground">
{download.totalFiles} files
{download.downloadUrls &&
download.downloadUrls.length > 1 &&
` (${download.downloadUrls.length} ZIPs)`}
</div>
)}
{download.expiresAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<AlertCircle className="h-3 w-3" />
Expires in{" "}
{formatExpirationTime(download.expiresAt)}
</div>
)}
{download.error && (
<p className="text-xs text-destructive">
{download.error}
</p>
)}
</div>
{download.status === "COMPLETED" &&
download.downloadUrls &&
download.downloadUrls.length > 0 &&
(download.downloadUrls.length === 1 ? (
<Button
size="sm"
onClick={() =>
handleDownload(download.downloadUrls![0])
}
>
<Download className="mr-1 h-3 w-3" />
Download
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
setExpandedDownloadId(
expandedDownloadId === download.id
? null
: download.id,
)
}
>
{expandedDownloadId === download.id ? (
<>
<ChevronUp className="mr-1 h-3 w-3" />
Hide
</>
) : (
<>
<ChevronDown className="mr-1 h-3 w-3" />
Show Downloads
</>
)}
</Button>
))}
{(download.status === "PENDING" ||
download.status === "PROCESSING") && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{download.progress}%
</div>
)}
</div>
{/* Expanded download parts */}
{expandedDownloadId === download.id &&
download.downloadUrls &&
download.downloadUrls.length > 1 && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<Button
size="sm"
className="w-full"
disabled={!!downloadProgress}
onClick={() =>
handleDownloadAll(
download.id,
download.downloadUrls!,
)
}
>
{downloadProgress?.downloadId === download.id ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Downloading {downloadProgress.current} of{" "}
{downloadProgress.total}...
</>
) : (
<>
<Download className="mr-2 h-3 w-3" />
Download All ({download.downloadUrls.length}{" "}
parts)
</>
)}
</Button>
<p className="text-xs text-muted-foreground">
Or download individually:
</p>
<div className="max-h-32 space-y-1 overflow-y-auto">
{download.downloadUrls.map((url, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload(url)}
>
<FileArchive className="mr-2 h-3 w-3" />
Part {index + 1} of{" "}
{download.downloadUrls!.length}
</Button>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
No previous downloads found
</div>
)}
<div className="border-t pt-4">
<Button
className="w-full"
onClick={startNewDownload}
disabled={isStartingDownload}
>
{isStartingDownload ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Start New Download
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}