feat(mentor-workspace): inline document preview matching applicant docs pattern

- Eye toggle expands the row below to embed FilePreview from
  @/components/shared/file-viewer (PDF iframe, image, video, Office docs)
- Download button uses explicit Content-Disposition: attachment via a
  new `disposition` input on workspaceGetFileDownloadUrl
- getPresignedUrl learns `inline: true` and optional `response-content-type`
  override so PDFs/images don't get force-downloaded by MinIO's default
- Eye button only renders for previewable mime types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-22 18:26:20 +02:00
parent ec92b03006
commit 48e48f058d
3 changed files with 92 additions and 14 deletions

View File

@@ -12,8 +12,9 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } 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 { formatDistanceToNow } from 'date-fns'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
interface Props { interface Props {
/** Project the workspace belongs to — drives file list (project-scoped). */ /** Project the workspace belongs to — drives file list (project-scoped). */
@@ -90,10 +91,43 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant
} }
} }
const [previewFileId, setPreviewFileId] = useState<string | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(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) => { const handleDownload = async (mentorFileId: string) => {
try { try {
const { url } = await downloadMutation.mutateAsync({ mentorFileId }) const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' })
window.open(url, '_blank') const a = document.createElement('a')
a.href = url
a.download = ''
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Download failed') toast.error(err instanceof Error ? err.message : 'Download failed')
} }
@@ -148,8 +182,12 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant
)} )}
<ul className="divide-y"> <ul className="divide-y">
{(files ?? []).map((f) => ( {(files ?? []).map((f) => {
<li key={f.id} className="flex items-center gap-3 py-3"> const isOpen = previewFileId === f.id
const previewable = canPreviewMime(f.mimeType, f.fileName)
return (
<li key={f.id} className="py-3 space-y-2">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" /> <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate">{f.fileName}</div> <div className="font-medium truncate">{f.fileName}</div>
@@ -167,7 +205,24 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant
<div className="text-xs text-muted-foreground mt-1">{f.description}</div> <div className="text-xs text-muted-foreground mt-1">{f.description}</div>
)} )}
</div> </div>
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}> {previewable && (
<Button
variant="ghost"
size="icon"
onClick={() => togglePreview(f)}
title={isOpen ? 'Close preview' : 'Preview'}
aria-label={isOpen ? 'Close preview' : 'Preview file'}
>
{isOpen ? <X className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(f.id)}
title="Download"
aria-label="Download file"
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
<AlertDialog> <AlertDialog>
@@ -191,8 +246,22 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div>
{isOpen && (
<div className="rounded-md border bg-muted/30 overflow-hidden">
{previewLoading || !previewUrl ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading preview
</div>
) : (
<FilePreview file={{ mimeType: f.mimeType, fileName: f.fileName }} url={previewUrl} />
)}
</div>
)}
</li> </li>
))} )
})}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -78,13 +78,17 @@ export async function getPresignedUrl(
objectKey: string, objectKey: string,
method: 'GET' | 'PUT' = 'GET', method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900, // 15 minutes default expirySeconds: number = 900, // 15 minutes default
options?: { downloadFileName?: string } options?: { downloadFileName?: string; inline?: boolean; contentType?: string }
): Promise<string> { ): Promise<string> {
const publicClient = getPublicMinioClient() const publicClient = getPublicMinioClient()
if (method === 'GET') { if (method === 'GET') {
const respHeaders = options?.downloadFileName let respHeaders: Record<string, string> | undefined
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } if (options?.inline) {
: undefined 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) return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
} else { } else {
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds) return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)

View File

@@ -2307,16 +2307,21 @@ export const mentorRouter = router({
* Issue a short-lived presigned GET URL to download a workspace file. * Issue a short-lived presigned GET URL to download a workspace file.
*/ */
workspaceGetFileDownloadUrl: protectedProcedure 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 }) => { .mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({ const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId }, 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' }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId) await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, 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 } return { url }
}), }),