project-standalo-sonic-cloud/app/components/UploadManager.tsx

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>
);
}