From abb6e6df83bae83688e7c546a3a0128881e6170d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 14:08:39 +0100 Subject: [PATCH] feat: inline document preview for applicant documents page 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 --- .../(applicant)/applicant/documents/page.tsx | 164 ++++++++++++------ 1 file changed, 108 insertions(+), 56 deletions(-) diff --git a/src/app/(applicant)/applicant/documents/page.tsx b/src/app/(applicant)/applicant/documents/page.tsx index eed3343..f906723 100644 --- a/src/app/(applicant)/applicant/documents/page.tsx +++ b/src/app/(applicant)/applicant/documents/page.tsx @@ -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 = { EXEC_SUMMARY: FileText, @@ -44,34 +49,114 @@ const fileTypeLabels: Record = { 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 ( -
- - +
+
+
+ +
+
+

{file.fileName}

+ {file.isLate && ( + + + Late + + )} +
+

+ {fileTypeLabels[file.fileType] || file.fileType} + {' - '} + {new Date(file.createdAt).toLocaleDateString()} +

+
+
+ {file.bucket && file.objectKey && ( +
+ {canPreview && ( + + )} + +
+ )} +
+ + {showPreview && ( +
+ {isLoadingPreview ? ( +
+ +
+ ) : previewData?.url ? ( + + ) : ( +
+ Failed to load preview +
+ )} +
+ )}
) } +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 ( + + ) +} + 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 (
@@ -204,41 +288,9 @@ export default function ApplicantDocumentsPage() { ) : (
{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 ( -
-
- -
-
-

{file.fileName}

- {fileRecord.isLate && ( - - - Late - - )} -
-

- {fileTypeLabels[file.fileType] || file.fileType} - {' - '} - {new Date(file.createdAt).toLocaleDateString()} -

-
-
- {fileRecord.bucket && fileRecord.objectKey && ( - - )} -
+ ) })}