Adds generateMentorObjectKey helper producing <projectName>/mentorship/<timestamp>-<file>. Replaces the client-supplied bucket/objectKey on workspaceUploadFile with an HMAC-signed upload token that binds bucket, objectKey, uploader, and a 1h expiry — paths can no longer be forged from the client. Adds workspaceGetUploadUrl, workspaceGetFiles, workspaceGetFileDownloadUrl, workspaceDeleteFile procedures with mentor-or-team-member auth. Builds <WorkspaceFilesPanel> and wires it into the mentor workspace Files tab and the applicant /applicant/mentor page. Replaces the file-promotion-panel mock array with a real workspaceGetFiles query. Tests cover token sign/verify (5), key construction (5), and end-to-end procedure flow including auth + tampered tokens (7). Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1 Plan: docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
7.2 KiB
TypeScript
194 lines
7.2 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 { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
|
interface Props {
|
|
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({ 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(
|
|
{ mentorAssignmentId },
|
|
{ enabled: !!mentorAssignmentId }
|
|
)
|
|
|
|
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
|
|
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
|
setDescription('')
|
|
toast.success('File uploaded')
|
|
},
|
|
})
|
|
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
|
|
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
|
|
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 handleDownload = async (mentorFileId: string) => {
|
|
try {
|
|
const { url } = await downloadMutation.mutateAsync({ mentorFileId })
|
|
window.open(url, '_blank')
|
|
} 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) => (
|
|
<li key={f.id} className="flex items-center gap-3 py-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>
|
|
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
|
|
<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>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|