feat: inline document preview for applicant documents page
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m28s

Replace "View" (opens new tab) with inline collapsible preview panel.
Supports PDF, video, image, and Office documents using existing
FilePreview component. Download button triggers native download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 14:08:39 +01:00
parent 8cdcc85555
commit abb6e6df83

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -13,6 +14,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot' import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
import { import {
FileText, FileText,
Upload, Upload,
@@ -22,7 +24,10 @@ import {
File, File,
Download, Download,
Eye, Eye,
X,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
const fileTypeIcons: Record<string, typeof FileText> = { const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText, EXEC_SUMMARY: FileText,
@@ -44,34 +49,114 @@ const fileTypeLabels: Record<string, string> = {
SUPPORTING_DOC: 'Supporting Document', SUPPORTING_DOC: 'Supporting Document',
} }
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) { function FileRow({ file }: { file: { id: string; fileName: string; fileType: string; createdAt: string | Date; isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string } }) {
const { data: viewData } = trpc.file.getDownloadUrl.useQuery( const [showPreview, setShowPreview] = useState(false)
{ bucket, objectKey, forDownload: false }, const Icon = fileTypeIcons[file.fileType] || File
{ staleTime: 10 * 60 * 1000 } const mimeType = file.mimeType || ''
const canPreview =
mimeType.startsWith('video/') ||
mimeType === 'application/pdf' ||
mimeType.startsWith('image/') ||
isOfficeFile(mimeType, file.fileName)
const { data: previewData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket!, objectKey: file.objectKey!, purpose: 'preview' as const },
{ enabled: showPreview && !!file.bucket && !!file.objectKey, staleTime: 10 * 60 * 1000 }
) )
const { data: dlData } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName },
{ staleTime: 10 * 60 * 1000 }
)
const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url
const dlUrl = typeof dlData === 'string' ? dlData : dlData?.url
return ( return (
<div className="flex items-center gap-1 shrink-0"> <div className="rounded-lg border overflow-hidden">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!viewUrl}> <div className="flex items-center justify-between p-3">
<a href={viewUrl || '#'} target="_blank" rel="noopener noreferrer"> <div className="flex items-center gap-3 min-w-0">
<Eye className="h-3 w-3" /> View <Icon className="h-5 w-5 text-muted-foreground shrink-0" />
</a> <div className="min-w-0">
</Button> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!dlUrl}> <p className="font-medium text-sm truncate">{file.fileName}</p>
<a href={dlUrl || '#'} download={fileName}> {file.isLate && (
<Download className="h-3 w-3" /> Download <Badge variant="warning" className="text-xs gap-1">
</a> <AlertTriangle className="h-3 w-3" />
</Button> Late
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
{' - '}
{new Date(file.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{file.bucket && file.objectKey && (
<div className="flex items-center gap-1 shrink-0">
{canPreview && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<><X className="h-3 w-3" /> Close</>
) : (
<><Eye className="h-3 w-3" /> View</>
)}
</Button>
)}
<DownloadButton bucket={file.bucket} objectKey={file.objectKey} fileName={file.fileName} />
</div>
)}
</div>
{showPreview && (
<div className="border-t bg-muted/50">
{isLoadingPreview ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewData?.url ? (
<FilePreview file={{ mimeType, fileName: file.fileName }} url={previewData.url} />
) : (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
Failed to load preview
</div>
)}
</div>
)}
</div> </div>
) )
} }
function DownloadButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const [downloading, setDownloading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
{ enabled: false }
)
const handleDownload = async () => {
setDownloading(true)
try {
const result = await refetch()
if (result.data?.url) {
window.location.href = result.data.url
}
} catch {
toast.error('Failed to download file')
} finally {
setTimeout(() => setDownloading(false), 1000)
}
}
return (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleDownload} disabled={downloading}>
{downloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
Download
</Button>
)
}
export default function ApplicantDocumentsPage() { export default function ApplicantDocumentsPage() {
const { status: sessionStatus } = useSession() const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated' const isAuthenticated = sessionStatus === 'authenticated'
@@ -113,7 +198,6 @@ export default function ApplicantDocumentsPage() {
} }
const { project, openRounds, isRejected } = data const { project, openRounds, isRejected } = data
const isDraft = !project.submittedAt
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -204,41 +288,9 @@ export default function ApplicantDocumentsPage() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{project.files.map((file) => { {project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null; bucket?: string; objectKey?: string }
return ( return (
<div <FileRow key={file.id} file={fileRecord} />
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3 min-w-0">
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Late
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
{' - '}
{new Date(file.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{fileRecord.bucket && fileRecord.objectKey && (
<FileActionButtons
bucket={fileRecord.bucket}
objectKey={fileRecord.objectKey}
fileName={file.fileName}
/>
)}
</div>
) )
})} })}
</div> </div>