- 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>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef } from 'react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { toast } from 'sonner'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog'
|
|
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). */
|
|
projectId: string
|
|
/**
|
|
* One MentorAssignment id on this project — needed only to mint upload tokens
|
|
* (the token is signed against the assignment + project pair, but the file
|
|
* itself is project-scoped so co-mentors see it).
|
|
*/
|
|
mentorAssignmentId: string
|
|
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
|
|
asApplicant?: boolean
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B'
|
|
const k = 1024
|
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
|
}
|
|
|
|
export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) {
|
|
const utils = trpc.useUtils()
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [description, setDescription] = useState('')
|
|
|
|
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
|
|
{ projectId },
|
|
{ enabled: !!projectId }
|
|
)
|
|
|
|
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
|
|
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.workspaceGetFiles.invalidate({ projectId })
|
|
setDescription('')
|
|
toast.success('File uploaded')
|
|
},
|
|
})
|
|
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
|
|
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.workspaceGetFiles.invalidate({ projectId })
|
|
toast.success('File deleted')
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
})
|
|
|
|
const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
e.target.value = ''
|
|
setUploading(true)
|
|
try {
|
|
const { uploadUrl, uploadToken } = await presign.mutateAsync({
|
|
mentorAssignmentId,
|
|
fileName: file.name,
|
|
mimeType: file.type || 'application/octet-stream',
|
|
size: file.size,
|
|
})
|
|
const putRes = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
body: file,
|
|
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
})
|
|
if (!putRes.ok) throw new Error(`Upload failed: HTTP ${putRes.status}`)
|
|
await recordUpload.mutateAsync({ uploadToken, description: description || undefined })
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
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, 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')
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Workspace Files</CardTitle></CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Skeleton className="h-12 w-full" />
|
|
<Skeleton className="h-12 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Workspace Files</CardTitle>
|
|
<CardDescription>
|
|
{asApplicant
|
|
? 'Files shared with your mentor in this workspace.'
|
|
: 'Files you and the team have shared in this workspace.'}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<Input
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Optional description for the next upload"
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
disabled={uploading}
|
|
onClick={() => inputRef.current?.click()}
|
|
className="shrink-0"
|
|
>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
{uploading ? 'Uploading…' : 'Upload file'}
|
|
</Button>
|
|
<input ref={inputRef} type="file" hidden onChange={handleFileSelected} />
|
|
</div>
|
|
|
|
{files && files.length === 0 && (
|
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
<FileText className="h-10 w-10 mx-auto mb-2 opacity-40" />
|
|
No files in this workspace yet.
|
|
</div>
|
|
)}
|
|
|
|
<ul className="divide-y">
|
|
{(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>
|
|
<div className="text-xs text-muted-foreground">
|
|
{f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '}
|
|
{formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })}
|
|
{f._count.comments > 0 && (
|
|
<span className="ml-2 inline-flex items-center gap-1">
|
|
<MessageSquare className="h-3 w-3" />
|
|
{f._count.comments}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{f.description && (
|
|
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
|
|
)}
|
|
</div>
|
|
{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>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete this file?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This removes the file from MinIO and the workspace. Comments on the file are deleted with it.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => deleteMutation.mutate({ mentorFileId: f.id })}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</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>
|
|
)
|
|
}
|