913 lines
29 KiB
TypeScript
913 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;
|
|
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;
|
|
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,
|
|
tag,
|
|
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,
|
|
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<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);
|
|
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<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>
|
|
);
|
|
}
|