'use client' import { useState, useCallback, useRef } from 'react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Upload, X, FileIcon, CheckCircle2, AlertCircle, Loader2, Film, FileText, Presentation, } from 'lucide-react' import { cn, formatFileSize } from '@/lib/utils' const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' interface UploadingFile { id: string file: File progress: number status: 'pending' | 'uploading' | 'complete' | 'error' error?: string fileType: FileType dbFileId?: string } interface FileUploadProps { projectId: string onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void onUploadError?: (error: Error) => void allowedTypes?: string[] multiple?: boolean className?: string roundId?: string availableRounds?: Array<{ id: string; name: string }> } // Map MIME types to suggested file types function suggestFileType(mimeType: string): FileType { if (mimeType.startsWith('video/')) return 'VIDEO' if (mimeType === 'application/pdf') return 'EXEC_SUMMARY' if ( mimeType.includes('presentation') || mimeType.includes('powerpoint') || mimeType.includes('slides') ) { return 'PRESENTATION' } return 'OTHER' } // Get icon for file type function getFileTypeIcon(fileType: FileType) { switch (fileType) { case 'VIDEO': return case 'EXEC_SUMMARY': return case 'PRESENTATION': return default: return } } export function FileUpload({ projectId, onUploadComplete, onUploadError, allowedTypes, multiple = true, className, roundId, availableRounds, }: FileUploadProps) { const [uploadingFiles, setUploadingFiles] = useState([]) const [isDragging, setIsDragging] = useState(false) const [selectedRoundId, setSelectedRoundId] = useState(roundId ?? null) const fileInputRef = useRef(null) const getUploadUrl = trpc.file.getUploadUrl.useMutation() const confirmUpload = trpc.file.confirmUpload.useMutation() const utils = trpc.useUtils() // Validate file const validateFile = useCallback( (file: File): string | null => { if (file.size > MAX_FILE_SIZE) { return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit` } if (allowedTypes && !allowedTypes.includes(file.type)) { return `File type ${file.type} is not allowed` } return null }, [allowedTypes] ) // Upload a single file const uploadFile = useCallback( async (uploadingFile: UploadingFile) => { const { file, id, fileType } = uploadingFile try { // Update status to uploading setUploadingFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f)) ) // Get pre-signed upload URL const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({ projectId, fileName: file.name, fileType, mimeType: file.type || 'application/octet-stream', size: file.size, roundId: selectedRoundId ?? undefined, }) // Store the DB file ID setUploadingFiles((prev) => prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f)) ) // Upload to MinIO using XHR for progress tracking await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const progress = Math.round((event.loaded / event.total) * 100) setUploadingFiles((prev) => prev.map((f) => (f.id === id ? { ...f, progress } : f)) ) } }) xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { resolve() } else { reject(new Error(`Upload failed with status ${xhr.status}`)) } }) xhr.addEventListener('error', () => { reject(new Error('Network error during upload')) }) xhr.addEventListener('abort', () => { reject(new Error('Upload aborted')) }) xhr.open('PUT', uploadUrl) xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') xhr.send(file) }) // Confirm upload await confirmUpload.mutateAsync({ fileId: dbFile.id }) // Update status to complete setUploadingFiles((prev) => prev.map((f) => f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f ) ) // Invalidate file list queries utils.file.listByProject.invalidate({ projectId }) // Notify parent onUploadComplete?.({ id: dbFile.id, fileName: file.name, fileType, }) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Upload failed' setUploadingFiles((prev) => prev.map((f) => f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f ) ) onUploadError?.(error instanceof Error ? error : new Error(errorMessage)) } }, [projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError] ) // Handle file selection const handleFiles = useCallback( (files: FileList | File[]) => { const fileArray = Array.from(files) const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean) const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => { const validationError = validateFile(file) return { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, file, progress: 0, status: validationError ? ('error' as const) : ('pending' as const), error: validationError || undefined, fileType: suggestFileType(file.type), } }) setUploadingFiles((prev) => [...prev, ...newUploadingFiles]) // Start uploading valid files newUploadingFiles .filter((f) => f.status === 'pending') .forEach((f) => uploadFile(f)) }, [multiple, validateFile, uploadFile] ) // Drag and drop handlers const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) }, []) const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) if (e.dataTransfer.files?.length) { handleFiles(e.dataTransfer.files) } }, [handleFiles] ) // File input change handler const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { if (e.target.files?.length) { handleFiles(e.target.files) } // Reset input so same file can be selected again e.target.value = '' }, [handleFiles] ) // Update file type for a pending file const updateFileType = useCallback((fileId: string, fileType: FileType) => { setUploadingFiles((prev) => prev.map((f) => (f.id === fileId ? { ...f, fileType } : f)) ) }, []) // Remove a file from the queue const removeFile = useCallback((fileId: string) => { setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId)) }, []) // Retry a failed upload const retryUpload = useCallback( (fileId: string) => { setUploadingFiles((prev) => prev.map((f) => f.id === fileId ? { ...f, status: 'pending' as const, progress: 0, error: undefined } : f ) ) const file = uploadingFiles.find((f) => f.id === fileId) if (file) { uploadFile({ ...file, status: 'pending', progress: 0, error: undefined }) } }, [uploadingFiles, uploadFile] ) const hasActiveUploads = uploadingFiles.some( (f) => f.status === 'pending' || f.status === 'uploading' ) return (
{/* Round selector */} {availableRounds && availableRounds.length > 0 && (
)} {/* Drop zone */}
fileInputRef.current?.click()} >

{isDragging ? 'Drop files here' : 'Drag and drop files here'}

or click to browse (max {formatFileSize(MAX_FILE_SIZE)})

{/* Upload queue */} {uploadingFiles.length > 0 && (
{uploadingFiles.map((uploadingFile) => (
{/* File type icon */}
{getFileTypeIcon(uploadingFile.fileType)}
{/* File info */}

{uploadingFile.file.name}

{formatFileSize(uploadingFile.file.size)}
{/* Progress bar or error message */} {uploadingFile.status === 'uploading' && (
{uploadingFile.progress}%
)} {uploadingFile.status === 'error' && (

{uploadingFile.error}

)} {uploadingFile.status === 'pending' && (
)}
{/* Status / Actions */}
{uploadingFile.status === 'pending' && ( )} {uploadingFile.status === 'uploading' && ( )} {uploadingFile.status === 'complete' && ( )} {uploadingFile.status === 'error' && (
)}
))} {/* Clear completed */} {uploadingFiles.some((f) => f.status === 'complete') && ( )}
)}
) }