Files
orchard/frontend/src/components/DragDropUpload.tsx
Mondo Diaz c4c9c20763 Remove tag system, use versions only for artifact references
Tags were mutable aliases that caused confusion alongside the immutable
version system. This removes tags entirely, keeping only PackageVersion
for artifact references.

Changes:
- Remove tags and tag_history tables (migration 012)
- Remove Tag model, TagRepository, and 6 tag API endpoints
- Update cache system to create versions instead of tags
- Update frontend to display versions instead of tags
- Remove tag-related schemas and types
- Update artifact cleanup service for version-based ref_count
2026-02-03 12:18:19 -06:00

904 lines
29 KiB
TypeScript

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;
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;
className?: string;
disabled?: boolean;
disabledReason?: 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<string> {
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 (
<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>
);
}
function PauseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
);
}
function WifiOffIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" />
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" />
<path d="M10.71 5.05A16 16 0 0 1 22.58 9" />
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<line x1="12" y1="20" x2="12.01" y2="20" />
</svg>
);
}
function SpinnerIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="spinner-icon">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" />
</svg>
);
}
export function DragDropUpload({
projectName,
packageName,
onUploadComplete,
onUploadError,
allowedTypes,
allowAllTypes = true,
maxFileSize,
maxConcurrentUploads = 3,
maxRetries = 3,
className = '',
disabled = false,
disabledReason,
}: DragDropUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragCounterRef = useRef(0);
const activeUploadsRef = useRef(0);
const xhrMapRef = useRef<Map<string, XMLHttpRequest>>(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<UploadResult> => {
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<string> {
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,
}),
}
);
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,
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,
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({}),
}
);
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, isOnline]);
const uploadFileSimple = useCallback((item: UploadItem): Promise<UploadResult> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrMapRef.current.set(item.id, xhr);
const formData = new FormData();
formData.append('file', item.file);
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]);
const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => {
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();
if (disabled) return;
dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}, [disabled]);
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;
if (disabled) return;
const files = e.dataTransfer.files;
if (files && files.length > 0) {
addFiles(files);
}
}, [addFiles, disabled]);
// Click to browse
const handleClick = useCallback(() => {
if (disabled) return;
fileInputRef.current?.click();
}, [disabled]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
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, disabled]);
// 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 (
<div className={`drag-drop-upload ${className}`}>
{!isOnline && (
<div className="offline-banner">
<WifiOffIcon />
<span>You're offline. Uploads will resume when connection is restored.</span>
</div>
)}
<div
className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''} ${disabled ? 'drop-zone--disabled' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleClick}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
aria-disabled={disabled}
title={disabled ? disabledReason : undefined}
>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
className="drop-zone__input"
accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined}
disabled={disabled}
/>
<div className="drop-zone__content">
<UploadIcon />
<p className="drop-zone__text">
{disabled ? (
<span>{disabledReason || 'Upload disabled'}</span>
) : (
<><strong>Drag files here</strong> or click to browse</>
)}
</p>
{!disabled && (
<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">
{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)`}
</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 /> :
item.status === 'paused' ? <PauseIcon /> :
item.status === 'validating' ? <SpinnerIcon /> :
<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>
)}
{item.status === 'validating' && (
<span className="upload-item__validating">Computing hash...</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' || (item.status === 'paused' && isOnline)) && (
<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' || item.status === 'paused') && (
<button
className="upload-item__btn upload-item__btn--remove"
onClick={() => removeItem(item.id)}
title="Remove"
type="button"
>
<RemoveIcon />
</button>
)}
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}