Files
MOPC-Portal/src/components/shared/requirement-upload-slot.tsx

485 lines
17 KiB
TypeScript
Raw Normal View History

'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 (
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
<a href={href || '#'} target="_blank" rel="noopener noreferrer">
<Eye className="h-3 w-3" /> View
</a>
</Button>
)
}
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 (
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
<a href={href || '#'} download={fileName}>
<Download className="h-3 w-3" /> Download
</a>
</Button>
)
}
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<HTMLInputElement>(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<HTMLInputElement>) => {
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<void>((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, purpose: 'preview' as const },
{ 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 (
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isFulfilled ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : requirement.isRequired ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium text-sm">{requirement.name}</span>
<Badge
variant={requirement.isRequired ? 'destructive' : 'secondary'}
className="text-xs shrink-0"
>
{requirement.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
{requirement.description && (
<p className="text-xs text-muted-foreground ml-6 mb-2">
{requirement.description}
</p>
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{[...new Set(requirement.acceptedMimeTypes.map(getMimeLabel))].map((label) => (
<Badge key={label} variant="outline" className="text-xs">
{label}
</Badge>
))}
{requirement.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {requirement.maxSizeMB}MB
</Badge>
)}
</div>
{existingFile && (
<div className="ml-6 space-y-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
{existingFile.pageCount != null && (
<span className="flex items-center gap-0.5">
<FileText className="h-3 w-3" />
{existingFile.pageCount} page{existingFile.pageCount !== 1 ? 's' : ''}
</span>
)}
{existingFile.detectedLang && existingFile.detectedLang !== 'und' && (
<span className="flex items-center gap-0.5">
<Languages className="h-3 w-3" />
{existingFile.detectedLang.toUpperCase()}
</span>
)}
</div>
{existingFile.bucket && existingFile.objectKey && (
<div className="flex items-center gap-1.5">
{canPreview && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<><X className="h-3 w-3" /> Close Preview</>
) : (
<><Play className="h-3 w-3" /> Preview</>
)}
</Button>
)}
<ViewFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} />
<DownloadFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} fileName={existingFile.fileName} />
</div>
)}
</div>
)}
{/* Inline preview panel */}
{showPreview && existingFile && (
<div className="ml-6 mt-2 rounded-lg border bg-muted/50 overflow-hidden">
{isLoadingPreview ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<FilePreview
file={{ mimeType: existingFile.mimeType, fileName: existingFile.fileName }}
url={previewUrl}
/>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
Failed to load preview
</div>
)}
</div>
)}
{uploading && (
<div className="ml-6 mt-2">
<Progress value={progress} className="h-1.5" />
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
</div>
)}
</div>
{!disabled && (
<div className="flex items-center gap-1 shrink-0">
{existingFile ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<RefreshCw className="mr-1 h-3 w-3" />
Replace
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
Upload
</Button>
)}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={acceptStr}
onChange={handleFileSelect}
/>
</div>
)
}
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 (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Required Documents
</h3>
{requirements.map((req) => {
const existing = files.find(
(f) => (f as { requirementId?: string | null }).requirementId === req.id
)
return (
<RequirementUploadSlot
key={req.id}
requirement={req}
existingFile={
existing
? {
id: existing.id,
fileName: existing.fileName,
mimeType: existing.mimeType,
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
bucket: (existing as { bucket?: string }).bucket,
objectKey: (existing as { objectKey?: string }).objectKey,
pageCount: (existing as { pageCount?: number | null }).pageCount,
detectedLang: (existing as { detectedLang?: string | null }).detectedLang,
analyzedAt: (existing as { analyzedAt?: string | null }).analyzedAt,
}
: null
}
projectId={projectId}
roundId={roundId}
onFileChange={handleFileChange}
disabled={disabled}
/>
)
})}
</div>
)
}