'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 { Upload, FileIcon, CheckCircle2, AlertCircle, Loader2, Trash2, RefreshCw, Eye, Download, FileText, Languages, Play, X, } from 'lucide-react' import { cn, formatFileSize } from '@/lib/utils' import { toast } from 'sonner' import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer' function getMimeLabel(mime: string): string { if (mime === 'application/pdf') return 'PDF' if (mime.startsWith('image/')) return 'Images' if (mime === 'video/mp4') return 'MP4' if (mime === 'video/quicktime') return 'MOV' if (mime === 'video/webm') return 'WebM' if (mime.startsWith('video/')) return 'Video' if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'Word' if (mime.includes('spreadsheetml')) return 'Excel' if (mime.includes('presentationml') || mime === 'application/vnd.ms-powerpoint') return 'PowerPoint' if (mime.endsWith('/*')) return mime.replace('/*', '') return mime } interface FileRequirement { id: string name: string description?: string | null acceptedMimeTypes: string[] maxSizeMB?: number | null isRequired: boolean } interface UploadedFile { id: string fileName: string mimeType: string size: number createdAt: string | Date requirementId?: string | null bucket?: string objectKey?: string pageCount?: number | null detectedLang?: string | null analyzedAt?: string | Date | null } interface RequirementUploadSlotProps { requirement: FileRequirement existingFile?: UploadedFile | null projectId: string roundId: string onFileChange?: () => void disabled?: boolean } function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) { const { data } = trpc.file.getDownloadUrl.useQuery( { bucket, objectKey, forDownload: false }, { staleTime: 10 * 60 * 1000 } ) const href = typeof data === 'string' ? data : data?.url return ( View ) } function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { const { data } = trpc.file.getDownloadUrl.useQuery( { bucket, objectKey, forDownload: true, fileName }, { staleTime: 10 * 60 * 1000 } ) const href = typeof data === 'string' ? data : data?.url return ( Download ) } export function RequirementUploadSlot({ requirement, existingFile, projectId, roundId, onFileChange, disabled = false, }: RequirementUploadSlotProps) { const [uploading, setUploading] = useState(false) const [progress, setProgress] = useState(0) const [deleting, setDeleting] = useState(false) const [showPreview, setShowPreview] = useState(false) const fileInputRef = useRef(null) const getUploadUrl = trpc.applicant.getUploadUrl.useMutation() const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation() const deleteFile = trpc.applicant.deleteFile.useMutation() const acceptsMime = useCallback( (mimeType: string) => { if (requirement.acceptedMimeTypes.length === 0) return true return requirement.acceptedMimeTypes.some((pattern) => { if (pattern.endsWith('/*')) { return mimeType.startsWith(pattern.replace('/*', '/')) } return mimeType === pattern }) }, [requirement.acceptedMimeTypes] ) const handleFileSelect = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return // Reset input if (fileInputRef.current) fileInputRef.current.value = '' // Validate mime type if (!acceptsMime(file.type)) { toast.error(`File type ${file.type} is not accepted for this requirement`) return } // Validate size if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) { toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`) return } setUploading(true) setProgress(0) try { // Get presigned URL const { url, bucket, objectKey, isLate, roundId: uploadRoundId } = await getUploadUrl.mutateAsync({ projectId, fileName: file.name, mimeType: file.type, fileType: 'OTHER', roundId, requirementId: requirement.id, }) // Upload file with progress tracking await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { setProgress(Math.round((event.loaded / event.total) * 100)) } }) 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('Upload failed'))) xhr.open('PUT', url) xhr.setRequestHeader('Content-Type', file.type) xhr.send(file) }) // Save metadata await saveFileMetadata.mutateAsync({ projectId, fileName: file.name, mimeType: file.type, size: file.size, fileType: 'OTHER', bucket, objectKey, roundId: uploadRoundId || roundId, isLate: isLate || false, requirementId: requirement.id, }) toast.success(`${requirement.name} uploaded successfully`) onFileChange?.() } catch (err) { toast.error(err instanceof Error ? err.message : 'Upload failed') } finally { setUploading(false) setProgress(0) } }, [projectId, roundId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange] ) const handleDelete = useCallback(async () => { if (!existingFile) return setDeleting(true) try { await deleteFile.mutateAsync({ fileId: existingFile.id }) toast.success('File deleted') onFileChange?.() } catch (err) { toast.error(err instanceof Error ? err.message : 'Delete failed') } finally { setDeleting(false) } }, [existingFile, deleteFile, onFileChange]) // Fetch preview URL only when preview is toggled on const { data: previewUrlData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery( { bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false }, { enabled: showPreview && !!existingFile?.bucket && !!existingFile?.objectKey, staleTime: 10 * 60 * 1000 } ) const previewUrl = typeof previewUrlData === 'string' ? previewUrlData : previewUrlData?.url const canPreview = existingFile ? existingFile.mimeType.startsWith('video/') || existingFile.mimeType === 'application/pdf' || existingFile.mimeType.startsWith('image/') || isOfficeFile(existingFile.mimeType, existingFile.fileName) : false const isFulfilled = !!existingFile const statusColor = isFulfilled ? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950' : requirement.isRequired ? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950' : 'border-muted' // Build accept string for file input const acceptStr = requirement.acceptedMimeTypes.length > 0 ? requirement.acceptedMimeTypes.join(',') : undefined return ( {isFulfilled ? ( ) : requirement.isRequired ? ( ) : ( )} {requirement.name} {requirement.isRequired ? 'Required' : 'Optional'} {requirement.description && ( {requirement.description} )} {[...new Set(requirement.acceptedMimeTypes.map(getMimeLabel))].map((label) => ( {label} ))} {requirement.maxSizeMB && ( Max {requirement.maxSizeMB}MB )} {existingFile && ( {existingFile.fileName} ({formatFileSize(existingFile.size)}) {existingFile.pageCount != null && ( {existingFile.pageCount} page{existingFile.pageCount !== 1 ? 's' : ''} )} {existingFile.detectedLang && existingFile.detectedLang !== 'und' && ( {existingFile.detectedLang.toUpperCase()} )} {existingFile.bucket && existingFile.objectKey && ( {canPreview && ( setShowPreview(!showPreview)} > {showPreview ? ( <> Close Preview> ) : ( <> Preview> )} )} )} )} {/* Inline preview panel */} {showPreview && existingFile && ( {isLoadingPreview ? ( ) : previewUrl ? ( ) : ( Failed to load preview )} )} {uploading && ( Uploading... {progress}% )} {!disabled && ( {existingFile ? ( <> fileInputRef.current?.click()} disabled={uploading} > Replace {deleting ? ( ) : ( )} > ) : ( fileInputRef.current?.click()} disabled={uploading} > {uploading ? ( ) : ( )} Upload )} )} ) } interface RequirementUploadListProps { projectId: string roundId: string disabled?: boolean } export function RequirementUploadList({ projectId, roundId, disabled }: RequirementUploadListProps) { const utils = trpc.useUtils() const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ roundId, }) const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, roundId }) if (requirements.length === 0) return null const handleFileChange = () => { utils.file.listByProject.invalidate({ projectId, roundId }) } return ( Required Documents {requirements.map((req) => { const existing = files.find( (f) => (f as { requirementId?: string | null }).requirementId === req.id ) return ( ) })} ) }
{requirement.description}
Uploading... {progress}%