507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef, useCallback } from 'react';
|
|
import type { UploadSession } from '../types/api';
|
|
import { API_PATHS } from '../types/api';
|
|
|
|
interface UploadFile {
|
|
id: string;
|
|
file: File;
|
|
progress: number;
|
|
status: 'pending' | 'uploading' | 'paused' | 'completed' | 'error';
|
|
uploadSession?: UploadSession;
|
|
error?: string;
|
|
}
|
|
|
|
interface UploadManagerProps {
|
|
onUploadComplete?: (fileId: string, file: File) => void;
|
|
onUploadError?: (error: string, file: File) => void;
|
|
maxFileSize?: number; // in bytes
|
|
allowedTypes?: string[];
|
|
maxConcurrentUploads?: number;
|
|
chunkSize?: number; // in bytes
|
|
}
|
|
|
|
export default function UploadManager({
|
|
onUploadComplete,
|
|
onUploadError,
|
|
maxFileSize = 100 * 1024 * 1024, // 100MB default
|
|
allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/ogg'],
|
|
maxConcurrentUploads = 3,
|
|
chunkSize = 1024 * 1024, // 1MB chunks
|
|
}: UploadManagerProps) {
|
|
const [files, setFiles] = useState<UploadFile[]>([]);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isPaused, setIsPaused] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const activeUploads = useRef<Map<string, AbortController>>(new Map());
|
|
|
|
// Validate file
|
|
const validateFile = (file: File): string | null => {
|
|
if (file.size > maxFileSize) {
|
|
return `File size exceeds ${Math.round(maxFileSize / (1024 * 1024))}MB limit`;
|
|
}
|
|
|
|
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
|
return `File type ${file.type} is not supported`;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Handle file selection
|
|
const handleFileSelect = (selectedFiles: FileList | null) => {
|
|
if (!selectedFiles) return;
|
|
|
|
const newFiles: UploadFile[] = [];
|
|
|
|
Array.from(selectedFiles).forEach(file => {
|
|
const error = validateFile(file);
|
|
const uploadFile: UploadFile = {
|
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
file,
|
|
progress: 0,
|
|
status: error ? 'error' : 'pending',
|
|
error: error || undefined,
|
|
};
|
|
newFiles.push(uploadFile);
|
|
});
|
|
|
|
setFiles(prev => [...prev, ...newFiles]);
|
|
};
|
|
|
|
// Initialize upload session
|
|
const initializeUpload = async (uploadFile: UploadFile): Promise<UploadSession | null> => {
|
|
try {
|
|
const response = await fetch(API_PATHS.UPLOAD_INIT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
fileName: uploadFile.file.name,
|
|
fileSize: uploadFile.file.size,
|
|
mimeType: uploadFile.file.type,
|
|
chunkSize,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to initialize upload');
|
|
}
|
|
|
|
const session: UploadSession = await response.json();
|
|
return session;
|
|
} catch (error) {
|
|
console.error('Upload initialization error:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Upload a single chunk
|
|
const uploadChunk = async (
|
|
uploadFile: UploadFile,
|
|
chunk: Blob,
|
|
chunkIndex: number,
|
|
uploadSession: UploadSession
|
|
): Promise<void> => {
|
|
const controller = new AbortController();
|
|
activeUploads.current.set(uploadFile.id, controller);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('chunk', chunk);
|
|
formData.append('chunkIndex', chunkIndex.toString());
|
|
formData.append('uploadId', uploadSession.id);
|
|
|
|
const chunkPath = API_PATHS.UPLOAD_CHUNK
|
|
.replace(':uploadId', uploadSession.id)
|
|
.replace(':chunkIndex', chunkIndex.toString());
|
|
|
|
const response = await fetch(chunkPath, {
|
|
method: 'POST',
|
|
body: formData,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to upload chunk ${chunkIndex}`);
|
|
}
|
|
} finally {
|
|
activeUploads.current.delete(uploadFile.id);
|
|
}
|
|
};
|
|
|
|
// Complete upload
|
|
const completeUpload = async (uploadSession: UploadSession): Promise<string | null> => {
|
|
try {
|
|
const response = await fetch(
|
|
API_PATHS.UPLOAD_COMPLETE.replace(':uploadId', uploadSession.id),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
uploadId: uploadSession.id,
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to complete upload');
|
|
}
|
|
|
|
const result = await response.json();
|
|
return result.fileId;
|
|
} catch (error) {
|
|
console.error('Upload completion error:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Upload file with chunks
|
|
const processFileUpload = async (uploadFile: UploadFile) => {
|
|
if (!uploadFile.uploadSession) return;
|
|
|
|
const { file, uploadSession } = uploadFile;
|
|
const totalChunks = Math.ceil(file.size / chunkSize);
|
|
let uploadedChunks = 0;
|
|
|
|
setFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadFile.id
|
|
? { ...f, status: 'uploading', progress: 0 }
|
|
: f
|
|
)
|
|
);
|
|
|
|
try {
|
|
// Upload chunks
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
// Check if paused
|
|
if (isPaused) {
|
|
await new Promise(resolve => {
|
|
const checkInterval = setInterval(() => {
|
|
if (!isPaused) {
|
|
clearInterval(checkInterval);
|
|
resolve(undefined);
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
const start = i * chunkSize;
|
|
const end = Math.min(start + chunkSize, file.size);
|
|
const chunk = file.slice(start, end);
|
|
|
|
await uploadChunk(uploadFile, chunk, i, uploadSession);
|
|
uploadedChunks++;
|
|
|
|
// Update progress
|
|
const progress = (uploadedChunks / totalChunks) * 100;
|
|
setFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadFile.id
|
|
? { ...f, progress }
|
|
: f
|
|
)
|
|
);
|
|
}
|
|
|
|
// Complete upload
|
|
const fileId = await completeUpload(uploadSession);
|
|
|
|
if (fileId) {
|
|
setFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadFile.id
|
|
? { ...f, status: 'completed', progress: 100 }
|
|
: f
|
|
)
|
|
);
|
|
onUploadComplete?.(fileId, file);
|
|
} else {
|
|
throw new Error('Failed to complete upload');
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
|
|
|
setFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadFile.id
|
|
? { ...f, status: 'error', error: errorMessage }
|
|
: f
|
|
)
|
|
);
|
|
onUploadError?.(errorMessage, file);
|
|
}
|
|
};
|
|
|
|
// Start upload process
|
|
const initializeAndStartUpload = async (uploadFile: UploadFile) => {
|
|
const session = await initializeUpload(uploadFile);
|
|
|
|
if (session) {
|
|
const updatedFile = { ...uploadFile, uploadSession: session };
|
|
setFiles(prev =>
|
|
prev.map(f => f.id === uploadFile.id ? updatedFile : f)
|
|
);
|
|
await processFileUpload(updatedFile);
|
|
} else {
|
|
setFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadFile.id
|
|
? { ...f, status: 'error', error: 'Failed to initialize upload' }
|
|
: f
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
// Process upload queue
|
|
const processQueue = useCallback(() => {
|
|
const pendingFiles = files.filter(f => f.status === 'pending');
|
|
const activeUploadsCount = files.filter(f => f.status === 'uploading').length;
|
|
|
|
if (isPaused || activeUploadsCount >= maxConcurrentUploads) return;
|
|
|
|
const slotsAvailable = maxConcurrentUploads - activeUploadsCount;
|
|
const filesToUpload = pendingFiles.slice(0, slotsAvailable);
|
|
|
|
filesToUpload.forEach(file => {
|
|
initializeAndStartUpload(file);
|
|
});
|
|
}, [files, isPaused, maxConcurrentUploads]);
|
|
|
|
// Auto-process queue when files change
|
|
React.useEffect(() => {
|
|
processQueue();
|
|
}, [files, processQueue]);
|
|
|
|
// Drag and drop handlers
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
handleFileSelect(e.dataTransfer.files);
|
|
};
|
|
|
|
// Pause/resume all uploads
|
|
const togglePause = () => {
|
|
setIsPaused(!isPaused);
|
|
|
|
if (!isPaused) {
|
|
// Cancel all active uploads
|
|
activeUploads.current.forEach(controller => {
|
|
controller.abort();
|
|
});
|
|
activeUploads.current.clear();
|
|
}
|
|
};
|
|
|
|
// Remove file from list
|
|
const removeFile = (id: string) => {
|
|
// Cancel if uploading
|
|
const controller = activeUploads.current.get(id);
|
|
if (controller) {
|
|
controller.abort();
|
|
activeUploads.current.delete(id);
|
|
}
|
|
|
|
setFiles(prev => prev.filter(f => f.id !== id));
|
|
};
|
|
|
|
// Retry failed upload
|
|
const retryUpload = (uploadFile: UploadFile) => {
|
|
setFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadFile.id
|
|
? { ...f, status: 'pending', progress: 0, error: undefined }
|
|
: f
|
|
)
|
|
);
|
|
};
|
|
|
|
// Clear completed uploads
|
|
const clearCompleted = () => {
|
|
setFiles(prev => prev.filter(f => f.status !== 'completed'));
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h2 className="text-xl font-semibold mb-4">Upload Manager</h2>
|
|
|
|
{/* Upload area */}
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
|
isDragging
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-gray-300 hover:border-gray-400'
|
|
}`}
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
<svg
|
|
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
|
|
<p className="text-gray-600 mb-2">
|
|
Drag and drop audio files here, or{' '}
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
browse
|
|
</button>
|
|
</p>
|
|
|
|
<p className="text-sm text-gray-500">
|
|
Supported formats: MP3, WAV, FLAC, OGG (max {Math.round(maxFileSize / (1024 * 1024))}MB)
|
|
</p>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={allowedTypes.join(',')}
|
|
className="hidden"
|
|
onChange={(e) => handleFileSelect(e.target.files)}
|
|
/>
|
|
</div>
|
|
|
|
{/* File list */}
|
|
{files.length > 0 && (
|
|
<div className="mt-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-medium">
|
|
Uploads ({files.length} file{files.length !== 1 ? 's' : ''})
|
|
</h3>
|
|
|
|
<div className="flex gap-2">
|
|
{files.some(f => f.status === 'uploading') && (
|
|
<button
|
|
onClick={togglePause}
|
|
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
|
|
>
|
|
{isPaused ? 'Resume' : 'Pause'}
|
|
</button>
|
|
)}
|
|
|
|
{files.some(f => f.status === 'completed') && (
|
|
<button
|
|
onClick={clearCompleted}
|
|
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
|
|
>
|
|
Clear Completed
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{files.map(uploadFile => (
|
|
<div
|
|
key={uploadFile.id}
|
|
className="border rounded-lg p-3 bg-gray-50"
|
|
>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{uploadFile.file.name}</p>
|
|
<p className="text-sm text-gray-500">
|
|
{(uploadFile.file.size / (1024 * 1024)).toFixed(2)} MB
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 ml-4">
|
|
{uploadFile.status === 'error' && (
|
|
<button
|
|
onClick={() => retryUpload(uploadFile)}
|
|
className="p-1 text-blue-600 hover:text-blue-700"
|
|
title="Retry"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{uploadFile.status !== 'uploading' && (
|
|
<button
|
|
onClick={() => removeFile(uploadFile.id)}
|
|
className="p-1 text-red-600 hover:text-red-700"
|
|
title="Remove"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status and progress */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className={`h-2 rounded-full transition-all ${
|
|
uploadFile.status === 'completed'
|
|
? 'bg-green-500'
|
|
: uploadFile.status === 'error'
|
|
? 'bg-red-500'
|
|
: 'bg-blue-500'
|
|
}`}
|
|
style={{ width: `${uploadFile.progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<span className="text-sm text-gray-600 min-w-0">
|
|
{uploadFile.status === 'pending' && 'Pending'}
|
|
{uploadFile.status === 'uploading' && `${Math.round(uploadFile.progress)}%`}
|
|
{uploadFile.status === 'paused' && 'Paused'}
|
|
{uploadFile.status === 'completed' && 'Completed'}
|
|
{uploadFile.status === 'error' && 'Error'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Error message */}
|
|
{uploadFile.error && (
|
|
<p className="text-sm text-red-600 mt-1">{uploadFile.error}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |