feat: inline document preview for applicant documents page
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m28s
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:
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
@@ -22,7 +24,10 @@ import {
|
||||
File,
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||
EXEC_SUMMARY: FileText,
|
||||
@@ -44,34 +49,114 @@ const fileTypeLabels: Record<string, string> = {
|
||||
SUPPORTING_DOC: 'Supporting Document',
|
||||
}
|
||||
|
||||
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
||||
const { data: viewData } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket, objectKey, forDownload: false },
|
||||
{ staleTime: 10 * 60 * 1000 }
|
||||
function FileRow({ file }: { file: { id: string; fileName: string; fileType: string; createdAt: string | Date; isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string } }) {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
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 (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!viewUrl}>
|
||||
<a href={viewUrl || '#'} target="_blank" rel="noopener noreferrer">
|
||||
<Eye className="h-3 w-3" /> View
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!dlUrl}>
|
||||
<a href={dlUrl || '#'} download={fileName}>
|
||||
<Download className="h-3 w-3" /> Download
|
||||
</a>
|
||||
</Button>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<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>
|
||||
{file.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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
@@ -113,7 +198,6 @@ export default function ApplicantDocumentsPage() {
|
||||
}
|
||||
|
||||
const { project, openRounds, isRejected } = data
|
||||
const isDraft = !project.submittedAt
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -204,41 +288,9 @@ export default function ApplicantDocumentsPage() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null; bucket?: string; objectKey?: string }
|
||||
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
<FileRow key={file.id} file={fileRecord} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user