diff --git a/src/components/mentor/workspace-files-panel.tsx b/src/components/mentor/workspace-files-panel.tsx index d5cce29..1b8320d 100644 --- a/src/components/mentor/workspace-files-panel.tsx +++ b/src/components/mentor/workspace-files-panel.tsx @@ -12,8 +12,9 @@ import { AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' -import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react' +import { Eye, FileText, Upload, Download, Trash2, MessageSquare, X, Loader2 } from 'lucide-react' import { formatDistanceToNow } from 'date-fns' +import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer' interface Props { /** Project the workspace belongs to — drives file list (project-scoped). */ @@ -90,10 +91,43 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant } } + const [previewFileId, setPreviewFileId] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + + const canPreviewMime = (m: string, name: string) => + m.startsWith('video/') || m === 'application/pdf' || m.startsWith('image/') || isOfficeFile(m, name) + + const togglePreview = async (file: { id: string; mimeType: string; fileName: string }) => { + if (previewFileId === file.id) { + setPreviewFileId(null) + setPreviewUrl(null) + return + } + setPreviewFileId(file.id) + setPreviewUrl(null) + setPreviewLoading(true) + try { + const { url } = await downloadMutation.mutateAsync({ mentorFileId: file.id, disposition: 'inline' }) + setPreviewUrl(url) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Preview failed') + setPreviewFileId(null) + } finally { + setPreviewLoading(false) + } + } + const handleDownload = async (mentorFileId: string) => { try { - const { url } = await downloadMutation.mutateAsync({ mentorFileId }) - window.open(url, '_blank') + const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' }) + const a = document.createElement('a') + a.href = url + a.download = '' + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + a.remove() } catch (err) { toast.error(err instanceof Error ? err.message : 'Download failed') } @@ -148,8 +182,12 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant )} diff --git a/src/lib/minio.ts b/src/lib/minio.ts index 5ab9a17..d031443 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -78,13 +78,17 @@ export async function getPresignedUrl( objectKey: string, method: 'GET' | 'PUT' = 'GET', expirySeconds: number = 900, // 15 minutes default - options?: { downloadFileName?: string } + options?: { downloadFileName?: string; inline?: boolean; contentType?: string } ): Promise { const publicClient = getPublicMinioClient() if (method === 'GET') { - const respHeaders = options?.downloadFileName - ? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } - : undefined + let respHeaders: Record | undefined + if (options?.inline) { + respHeaders = { 'response-content-disposition': 'inline' } + if (options.contentType) respHeaders['response-content-type'] = options.contentType + } else if (options?.downloadFileName) { + respHeaders = { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } + } return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders) } else { return publicClient.presignedPutObject(bucket, objectKey, expirySeconds) diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index d5c01b7..c2b2686 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -2307,16 +2307,21 @@ export const mentorRouter = router({ * Issue a short-lived presigned GET URL to download a workspace file. */ workspaceGetFileDownloadUrl: protectedProcedure - .input(z.object({ mentorFileId: z.string() })) + .input(z.object({ + mentorFileId: z.string(), + disposition: z.enum(['inline', 'attachment']).default('attachment'), + })) .mutation(async ({ ctx, input }) => { const file = await ctx.prisma.mentorFile.findUnique({ where: { id: input.mentorFileId }, - select: { bucket: true, objectKey: true, fileName: true, projectId: true }, + select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId) const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, - { downloadFileName: file.fileName }) + input.disposition === 'inline' + ? { inline: true, contentType: file.mimeType } + : { downloadFileName: file.fileName }) return { url } }),