Add drag-and-drop upload component with progress tracking (#8)

This commit is contained in:
Mondo Diaz
2026-01-07 13:49:45 -06:00
parent bccbc71c13
commit 961f6ff6b4
5 changed files with 898 additions and 43 deletions

View File

@@ -0,0 +1,569 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import './DragDropUpload.css';
// Types
export type UploadStatus = 'pending' | 'uploading' | 'complete' | 'failed' | 'validating';
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 {
// Handle compound extensions like .tar.gz
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]}` : '';
}
// Icons
function UploadIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function ErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
}
function RetryIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
);
}
function RemoveIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
function FileIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
}
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<UploadItem[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragCounterRef = useRef(0);
const activeUploadsRef = useRef(0);
// 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]);
// Upload a single file with progress tracking
const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
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', () => {
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', () => {
reject(new Error('Network error - check your connection'));
});
xhr.addEventListener('timeout', () => {
reject(new Error('Upload timed out'));
});
xhr.open('POST', `/api/v1/project/${projectName}/${packageName}/upload`);
xhr.timeout = 300000; // 5 minute timeout
xhr.send(formData);
// Store xhr for potential cancellation
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'uploading' as UploadStatus, startTime: Date.now() }
: u
));
});
}, [projectName, packageName, tag]);
// Process upload queue
const processQueue = useCallback(async () => {
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) {
const errorMessage = err instanceof Error ? err.message : 'Upload failed';
const shouldRetry = item.retryCount < maxRetries &&
(errorMessage.includes('Network') || errorMessage.includes('timeout'));
if (shouldRetry) {
// Exponential backoff retry
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]);
// Process queue when items are added or status changes
useEffect(() => {
const hasPending = uploadQueue.some(item => item.status === 'pending');
if (hasPending && activeUploadsRef.current < maxConcurrentUploads) {
processQueue();
}
// Notify when all uploads complete
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]);
// 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<HTMLInputElement>) => {
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;
return (
<div className={`drag-drop-upload ${className}`}>
{/* Drop Zone */}
<div
className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
className="drop-zone__input"
accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined}
/>
<div className="drop-zone__content">
<UploadIcon />
<p className="drop-zone__text">
<strong>Drag files here</strong> or click to browse
</p>
<p className="drop-zone__hint">
{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`}
{!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`}
</p>
</div>
</div>
{/* Upload Queue */}
{uploadQueue.length > 0 && (
<div className="upload-queue">
<div className="upload-queue__header">
<span className="upload-queue__title">
{uploadingCount > 0
? `Uploading ${uploadingCount} of ${uploadQueue.length} files`
: `${completedCount} of ${uploadQueue.length} files uploaded`
}
{failedCount > 0 && ` (${failedCount} failed)`}
</span>
{(completedCount > 0 || failedCount > 0) && (
<button
className="upload-queue__clear"
onClick={clearCompleted}
type="button"
>
Clear finished
</button>
)}
</div>
{/* Overall progress bar */}
{uploadingCount > 0 && (
<div className="upload-queue__overall">
<div className="progress-bar">
<div
className="progress-bar__fill"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="progress-bar__text">{overallProgress}%</span>
</div>
)}
{/* Individual file items */}
<ul className="upload-queue__list">
{uploadQueue.map(item => (
<li key={item.id} className={`upload-item upload-item--${item.status}`}>
<div className="upload-item__icon">
{item.status === 'complete' ? <CheckIcon /> :
item.status === 'failed' ? <ErrorIcon /> :
<FileIcon />}
</div>
<div className="upload-item__info">
<div className="upload-item__name" title={item.file.name}>
{item.file.name}
</div>
<div className="upload-item__meta">
<span className="upload-item__size">{formatBytes(item.file.size)}</span>
{item.status === 'uploading' && item.speed > 0 && (
<>
<span className="upload-item__speed">{formatSpeed(item.speed)}</span>
{item.startTime && (
<span className="upload-item__eta">
{formatTimeRemaining(
(item.file.size - (item.file.size * item.progress / 100)) / item.speed
)} remaining
</span>
)}
</>
)}
{item.status === 'complete' && item.artifactId && (
<span className="upload-item__artifact">
ID: {item.artifactId.substring(0, 12)}...
</span>
)}
{item.error && (
<span className="upload-item__error">{item.error}</span>
)}
{item.retryCount > 0 && item.status === 'uploading' && (
<span className="upload-item__retry-count">Retry {item.retryCount}</span>
)}
</div>
{item.status === 'uploading' && (
<div className="progress-bar progress-bar--small">
<div
className="progress-bar__fill"
style={{ width: `${item.progress}%` }}
/>
</div>
)}
</div>
<div className="upload-item__actions">
{item.status === 'failed' && (
<button
className="upload-item__btn upload-item__btn--retry"
onClick={() => retryItem(item.id)}
title="Retry upload"
type="button"
>
<RetryIcon />
</button>
)}
{(item.status === 'complete' || item.status === 'failed' || item.status === 'pending') && (
<button
className="upload-item__btn upload-item__btn--remove"
onClick={() => removeItem(item.id)}
title="Remove"
type="button"
>
<RemoveIcon />
</button>
)}
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}