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:
@@ -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<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) => {
|
||||
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
|
||||
)}
|
||||
|
||||
<ul className="divide-y">
|
||||
{(files ?? []).map((f) => (
|
||||
<li key={f.id} className="flex items-center gap-3 py-3">
|
||||
{(files ?? []).map((f) => {
|
||||
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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
<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" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
@@ -191,8 +246,22 @@ export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user