import { useState, useRef, useCallback, useEffect } from 'react'; import './DragDropUpload.css'; const CHUNK_SIZE = 10 * 1024 * 1024; const CHUNKED_UPLOAD_THRESHOLD = 100 * 1024 * 1024; const UPLOAD_STATE_PREFIX = 'orchard_upload_'; interface StoredUploadState { uploadId: string; fileHash: string; filename: string; fileSize: number; completedParts: number[]; project: string; package: string; tag?: string; createdAt: number; } function getUploadStateKey(project: string, pkg: string, fileHash: string): string { return `${UPLOAD_STATE_PREFIX}${project}_${pkg}_${fileHash}`; } function saveUploadState(state: StoredUploadState): void { try { const key = getUploadStateKey(state.project, state.package, state.fileHash); localStorage.setItem(key, JSON.stringify(state)); } catch { // localStorage might be full or unavailable } } function loadUploadState(project: string, pkg: string, fileHash: string): StoredUploadState | null { try { const key = getUploadStateKey(project, pkg, fileHash); const stored = localStorage.getItem(key); if (!stored) return null; const state = JSON.parse(stored) as StoredUploadState; const oneDay = 24 * 60 * 60 * 1000; if (Date.now() - state.createdAt > oneDay) { localStorage.removeItem(key); return null; } return state; } catch { return null; } } function clearUploadState(project: string, pkg: string, fileHash: string): void { try { const key = getUploadStateKey(project, pkg, fileHash); localStorage.removeItem(key); } catch { // ignore } } // Types export type UploadStatus = 'pending' | 'uploading' | 'complete' | 'failed' | 'validating' | 'paused'; export interface UploadItem { id: string; file: File; status: UploadStatus; progress: number; speed: number; // bytes per second error?: string; artifactId?: string; retryCount: number; startTime?: number; } export interface UploadResult { artifact_id: string; size: number; deduplicated?: boolean; } export interface DragDropUploadProps { projectName: string; packageName: string; onUploadComplete?: (results: UploadResult[]) => void; onUploadError?: (error: string) => void; allowedTypes?: string[]; // e.g., ['.tar.gz', '.zip', '.deb'] allowAllTypes?: boolean; maxFileSize?: number; // in bytes maxConcurrentUploads?: number; maxRetries?: number; tag?: string; className?: string; } // Utility functions function generateId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function formatSpeed(bytesPerSecond: number): string { return `${formatBytes(bytesPerSecond)}/s`; } function formatTimeRemaining(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return '--:--'; if (seconds < 60) return `${Math.round(seconds)}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } function getFileExtension(filename: string): string { const parts = filename.toLowerCase().split('.'); if (parts.length >= 3 && parts[parts.length - 2] === 'tar') { return `.${parts.slice(-2).join('.')}`; } return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; } async function computeSHA256(file: File): Promise { const buffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } // Icons function UploadIcon() { return ( ); } function CheckIcon() { return ( ); } function ErrorIcon() { return ( ); } function RetryIcon() { return ( ); } function RemoveIcon() { return ( ); } function FileIcon() { return ( ); } function PauseIcon() { return ( ); } function WifiOffIcon() { return ( ); } function SpinnerIcon() { return ( ); } export function DragDropUpload({ projectName, packageName, onUploadComplete, onUploadError, allowedTypes, allowAllTypes = true, maxFileSize, maxConcurrentUploads = 3, maxRetries = 3, tag, className = '', }: DragDropUploadProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploadQueue, setUploadQueue] = useState([]); const [isOnline, setIsOnline] = useState(navigator.onLine); const fileInputRef = useRef(null); const dragCounterRef = useRef(0); const activeUploadsRef = useRef(0); const xhrMapRef = useRef>(new Map()); // Online/Offline detection useEffect(() => { const handleOnline = () => { setIsOnline(true); // Resume paused uploads setUploadQueue(prev => prev.map(item => item.status === 'paused' ? { ...item, status: 'pending' as UploadStatus, error: undefined } : item )); }; const handleOffline = () => { setIsOnline(false); // Pause uploading items and cancel their XHR requests setUploadQueue(prev => prev.map(item => { if (item.status === 'uploading') { // Abort the XHR request const xhr = xhrMapRef.current.get(item.id); if (xhr) { xhr.abort(); xhrMapRef.current.delete(item.id); } return { ...item, status: 'paused' as UploadStatus, error: 'Network offline - will resume when connection is restored', progress: 0 }; } if (item.status === 'pending') { return { ...item, status: 'paused' as UploadStatus, error: 'Network offline - waiting for connection' }; } return item; })); }; window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); // Validate a single file const validateFile = useCallback((file: File): string | null => { // Check file size if (maxFileSize && file.size > maxFileSize) { return `File exceeds ${formatBytes(maxFileSize)} limit`; } // Check file type if not allowing all types if (!allowAllTypes && allowedTypes && allowedTypes.length > 0) { const ext = getFileExtension(file.name); if (!allowedTypes.some(t => t.toLowerCase() === ext)) { return `File type ${ext || 'unknown'} not allowed. Accepted: ${allowedTypes.join(', ')}`; } } // Check for empty file if (file.size === 0) { return 'Cannot upload empty file'; } return null; }, [allowedTypes, allowAllTypes, maxFileSize]); // Add files to queue const addFiles = useCallback((files: FileList | File[]) => { const newItems: UploadItem[] = Array.from(files).map(file => { const validationError = validateFile(file); return { id: generateId(), file, status: validationError ? 'failed' : 'pending', progress: 0, speed: 0, error: validationError || undefined, retryCount: 0, }; }); setUploadQueue(prev => [...prev, ...newItems]); }, [validateFile]); const uploadFileChunked = useCallback(async (item: UploadItem): Promise => { setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'validating' as UploadStatus, startTime: Date.now() } : u )); const fileHash = await computeSHA256(item.file); const storedState = loadUploadState(projectName, packageName, fileHash); let uploadId: string; let completedParts: number[] = []; if (storedState && storedState.fileSize === item.file.size && storedState.filename === item.file.name) { try { const statusResponse = await fetch( `/api/v1/project/${projectName}/${packageName}/upload/${storedState.uploadId}/status` ); if (statusResponse.ok) { const statusData = await statusResponse.json(); uploadId = storedState.uploadId; completedParts = statusData.uploaded_parts || []; } else { throw new Error('Stored upload no longer valid'); } } catch { clearUploadState(projectName, packageName, fileHash); uploadId = await initNewUpload(); } } else { uploadId = await initNewUpload(); } async function initNewUpload(): Promise { const initResponse = await fetch( `/api/v1/project/${projectName}/${packageName}/upload/init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expected_hash: fileHash, filename: item.file.name, size: item.file.size, tag: tag || undefined, }), } ); if (!initResponse.ok) { const error = await initResponse.json().catch(() => ({})); throw new Error(error.detail || `Init failed: ${initResponse.status}`); } const initData = await initResponse.json(); if (initData.already_exists) { throw { deduplicated: true, artifact_id: initData.artifact_id }; } saveUploadState({ uploadId: initData.upload_id, fileHash, filename: item.file.name, fileSize: item.file.size, completedParts: [], project: projectName, package: packageName, tag: tag || undefined, createdAt: Date.now(), }); return initData.upload_id; } const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE); let uploadedBytes = completedParts.length * CHUNK_SIZE; if (uploadedBytes > item.file.size) uploadedBytes = item.file.size - (item.file.size % CHUNK_SIZE); const startTime = Date.now(); for (let partNumber = 1; partNumber <= totalChunks; partNumber++) { if (completedParts.includes(partNumber)) { continue; } if (!isOnline) { throw new Error('Network offline'); } const start = (partNumber - 1) * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, item.file.size); const chunk = item.file.slice(start, end); const partResponse = await fetch( `/api/v1/project/${projectName}/${packageName}/upload/${uploadId}/part/${partNumber}`, { method: 'PUT', body: chunk, } ); if (!partResponse.ok) { throw new Error(`Part ${partNumber} upload failed: ${partResponse.status}`); } completedParts.push(partNumber); saveUploadState({ uploadId, fileHash, filename: item.file.name, fileSize: item.file.size, completedParts, project: projectName, package: packageName, tag: tag || undefined, createdAt: Date.now(), }); uploadedBytes += chunk.size; const elapsed = (Date.now() - startTime) / 1000; const speed = elapsed > 0 ? uploadedBytes / elapsed : 0; const progress = Math.round((uploadedBytes / item.file.size) * 100); setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, progress, speed, status: 'uploading' as UploadStatus } : u )); } const completeResponse = await fetch( `/api/v1/project/${projectName}/${packageName}/upload/${uploadId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag: tag || undefined }), } ); if (!completeResponse.ok) { throw new Error(`Complete failed: ${completeResponse.status}`); } clearUploadState(projectName, packageName, fileHash); const completeData = await completeResponse.json(); return { artifact_id: completeData.artifact_id, size: completeData.size, deduplicated: false, }; }, [projectName, packageName, tag, isOnline]); const uploadFileSimple = useCallback((item: UploadItem): Promise => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhrMapRef.current.set(item.id, xhr); const formData = new FormData(); formData.append('file', item.file); if (tag) { formData.append('tag', tag); } let lastLoaded = 0; let lastTime = Date.now(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const now = Date.now(); const timeDiff = (now - lastTime) / 1000; const loadedDiff = e.loaded - lastLoaded; const speed = timeDiff > 0 ? loadedDiff / timeDiff : 0; const progress = Math.round((e.loaded / e.total) * 100); setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, progress, speed, status: 'uploading' as UploadStatus } : u )); lastLoaded = e.loaded; lastTime = now; } }); xhr.addEventListener('load', () => { xhrMapRef.current.delete(item.id); if (xhr.status >= 200 && xhr.status < 300) { try { const result = JSON.parse(xhr.responseText) as UploadResult; resolve(result); } catch { reject(new Error('Invalid response from server')); } } else { try { const error = JSON.parse(xhr.responseText); reject(new Error(error.detail || `Upload failed: ${xhr.status}`)); } catch { reject(new Error(`Upload failed: ${xhr.status}`)); } } }); xhr.addEventListener('error', () => { xhrMapRef.current.delete(item.id); reject(new Error('Network error - check your connection')); }); xhr.addEventListener('timeout', () => { xhrMapRef.current.delete(item.id); reject(new Error('Upload timed out')); }); xhr.addEventListener('abort', () => { xhrMapRef.current.delete(item.id); reject(new Error('Upload cancelled')); }); xhr.open('POST', `/api/v1/project/${projectName}/${packageName}/upload`); xhr.timeout = 300000; xhr.send(formData); setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'uploading' as UploadStatus, startTime: Date.now() } : u )); }); }, [projectName, packageName, tag]); const uploadFile = useCallback((item: UploadItem): Promise => { if (item.file.size >= CHUNKED_UPLOAD_THRESHOLD) { return uploadFileChunked(item); } return uploadFileSimple(item); }, [uploadFileChunked, uploadFileSimple]); const processQueue = useCallback(async () => { if (!isOnline) return; const pendingItems = uploadQueue.filter(item => item.status === 'pending'); for (const item of pendingItems) { if (activeUploadsRef.current >= maxConcurrentUploads) { break; } activeUploadsRef.current++; // Start upload setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'uploading' as UploadStatus } : u )); try { const result = await uploadFile(item); setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'complete' as UploadStatus, progress: 100, artifactId: result.artifact_id } : u )); } catch (err: unknown) { const dedupErr = err as { deduplicated?: boolean; artifact_id?: string }; if (dedupErr.deduplicated && dedupErr.artifact_id) { setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'complete' as UploadStatus, progress: 100, artifactId: dedupErr.artifact_id } : u )); } else { const errorMessage = err instanceof Error ? err.message : 'Upload failed'; const shouldRetry = item.retryCount < maxRetries && (errorMessage.includes('Network') || errorMessage.includes('timeout')); if (shouldRetry) { const delay = Math.pow(2, item.retryCount) * 1000; setTimeout(() => { setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'pending' as UploadStatus, retryCount: u.retryCount + 1, progress: 0 } : u )); }, delay); } else { setUploadQueue(prev => prev.map(u => u.id === item.id ? { ...u, status: 'failed' as UploadStatus, error: errorMessage } : u )); onUploadError?.(errorMessage); } } } finally { activeUploadsRef.current--; } } }, [uploadQueue, maxConcurrentUploads, maxRetries, uploadFile, onUploadError, isOnline]); useEffect(() => { const hasPending = uploadQueue.some(item => item.status === 'pending'); if (hasPending && activeUploadsRef.current < maxConcurrentUploads && isOnline) { processQueue(); } const allComplete = uploadQueue.length > 0 && uploadQueue.every(item => item.status === 'complete' || item.status === 'failed'); if (allComplete) { const completedResults = uploadQueue .filter(item => item.status === 'complete' && item.artifactId) .map(item => ({ artifact_id: item.artifactId!, size: item.file.size, })); if (completedResults.length > 0) { onUploadComplete?.(completedResults); } } }, [uploadQueue, maxConcurrentUploads, processQueue, onUploadComplete, isOnline]); // Drag event handlers const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragOver(true); } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragOver(false); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); dragCounterRef.current = 0; const files = e.dataTransfer.files; if (files && files.length > 0) { addFiles(files); } }, [addFiles]); // Click to browse const handleClick = useCallback(() => { fileInputRef.current?.click(); }, []); const handleFileChange = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { addFiles(files); } // Reset input so same file can be selected again if (fileInputRef.current) { fileInputRef.current.value = ''; } }, [addFiles]); // Remove item from queue const removeItem = useCallback((id: string) => { setUploadQueue(prev => prev.filter(item => item.id !== id)); }, []); // Retry failed upload const retryItem = useCallback((id: string) => { setUploadQueue(prev => prev.map(item => item.id === id ? { ...item, status: 'pending' as UploadStatus, error: undefined, progress: 0, retryCount: 0 } : item )); }, []); // Clear completed/failed items const clearCompleted = useCallback(() => { setUploadQueue(prev => prev.filter(item => item.status !== 'complete' && item.status !== 'failed' )); }, []); // Calculate overall progress const overallProgress = uploadQueue.length > 0 ? Math.round(uploadQueue.reduce((sum, item) => sum + item.progress, 0) / uploadQueue.length) : 0; const completedCount = uploadQueue.filter(item => item.status === 'complete').length; const failedCount = uploadQueue.filter(item => item.status === 'failed').length; const uploadingCount = uploadQueue.filter(item => item.status === 'uploading').length; const pausedCount = uploadQueue.filter(item => item.status === 'paused').length; return (
{!isOnline && (
You're offline. Uploads will resume when connection is restored.
)}
e.key === 'Enter' && handleClick()} >

Drag files here or click to browse

{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`}

{/* Upload Queue */} {uploadQueue.length > 0 && (
{pausedCount > 0 && !isOnline ? `${pausedCount} uploads paused (offline)` : uploadingCount > 0 ? `Uploading ${uploadingCount} of ${uploadQueue.length} files` : `${completedCount} of ${uploadQueue.length} files uploaded` } {failedCount > 0 && ` (${failedCount} failed)`} {(completedCount > 0 || failedCount > 0) && ( )}
{/* Overall progress bar */} {uploadingCount > 0 && (
{overallProgress}%
)} {/* Individual file items */}
    {uploadQueue.map(item => (
  • {item.status === 'complete' ? : item.status === 'failed' ? : item.status === 'paused' ? : item.status === 'validating' ? : }
    {item.file.name}
    {formatBytes(item.file.size)} {item.status === 'uploading' && item.speed > 0 && ( <> {formatSpeed(item.speed)} {item.startTime && ( {formatTimeRemaining( (item.file.size - (item.file.size * item.progress / 100)) / item.speed )} remaining )} )} {item.status === 'complete' && item.artifactId && ( ID: {item.artifactId.substring(0, 12)}... )} {item.error && ( {item.error} )} {item.retryCount > 0 && item.status === 'uploading' && ( Retry {item.retryCount} )} {item.status === 'validating' && ( Computing hash... )}
    {item.status === 'uploading' && (
    )}
    {(item.status === 'failed' || (item.status === 'paused' && isOnline)) && ( )} {(item.status === 'complete' || item.status === 'failed' || item.status === 'pending' || item.status === 'paused') && ( )}
  • ))}
)}
); }