/** Attachment metadata/CRUD over records plus binary storage in R2. */ import { useState, useCallback, useRef, useMemo, useEffect } from 'react' import { useUser, useQuery, useMutations, useR2Files, ROLES, formatFileSize, type Role, } from 'deepspace' import { Button, Modal, EmptyState, Badge, useToast } from '@/components/ui' import { AttachmentPreview, AttachmentThumbnail } from '../components/AttachmentPreview' import { useMimeTypeDetection } from '../hooks/useMimeTypeDetection' const MAX_FILE_SIZE = 50 * 1024 * 1024 interface Attachment { fileName: string fileKey: string mimeType: string fileSize: number ownerId: string } const CATEGORY_LABELS: Record = { all: 'All', image: 'Images', video: 'Videos', audio: 'Audio', pdf: 'PDFs', code: 'Code', text: 'Text', spreadsheet: 'Spreadsheets', document: 'Documents', presentation: 'Presentations', archive: 'Archives', other: 'Other', } interface UploadModalProps { isOpen: boolean onClose: () => void onUpload: (file: File, detectedMime: string) => Promise isUploading: boolean } function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProps) { const [selectedFile, setSelectedFile] = useState(null) const [detectedType, setDetectedType] = useState(null) const [error, setError] = useState(null) const inputRef = useRef(null) const selectedFileRef = useRef(null) const { detectMimeType } = useMimeTypeDetection() const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return if (file.size > MAX_FILE_SIZE) { selectedFileRef.current = null setSelectedFile(null) setDetectedType(null) setError(`File too large. Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`) e.currentTarget.value = '' return } setError(null) selectedFileRef.current = file setSelectedFile(file) setDetectedType(null) const mime = await detectMimeType(file) if (selectedFileRef.current === file) setDetectedType(mime) } const handleUpload = async () => { if (!selectedFile) return const file = selectedFile const mime = detectedType || (await detectMimeType(file)) if (selectedFileRef.current !== file) return const uploaded = await onUpload(file, mime) if (!uploaded) return selectedFileRef.current = null setSelectedFile(null) setDetectedType(null) setError(null) if (inputRef.current) inputRef.current.value = '' onClose() } const handleClose = () => { selectedFileRef.current = null setSelectedFile(null) setDetectedType(null) setError(null) if (inputRef.current) inputRef.current.value = '' onClose() } return ( Upload File
inputRef.current?.click()} className="border-2 border-dashed border-border rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-colors" > {selectedFile ? (

{selectedFile.name}

{formatFileSize(selectedFile.size)} · {detectedType || selectedFile.type || 'detecting...'}

) : (

Click to select a file

Max {formatFileSize(MAX_FILE_SIZE)}

)}
{error &&

{error}

}
) } function PreviewModal({ isOpen, onClose, attachment, previewUrl, onDownload }: { isOpen: boolean onClose: () => void attachment: { data: Attachment; recordId: string } | null previewUrl: string | null onDownload: () => void }) { if (!attachment) return null const { fileName, mimeType, fileSize } = attachment.data return ( {fileName}
{previewUrl ? ( ) : ( )}
{formatFileSize(fileSize)} · {mimeType}
) } export default function AttachmentsPage() { const { user } = useUser() const userRole = (user?.role ?? ROLES.VIEWER) as Role const canUpload = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN const isAdmin = userRole === ROLES.ADMIN const { getFileCategory, canPreview } = useMimeTypeDetection() const toast = useToast() const [showUploadModal, setShowUploadModal] = useState(false) const [previewAttachment, setPreviewAttachment] = useState<{ data: Attachment; recordId: string } | null>(null) const [deleteTarget, setDeleteTarget] = useState<{ recordId: string; fileKey: string; fileName: string } | null>(null) const [filter, setFilter] = useState('all') const { upload, downloadFile, readFile, deleteFile, isUploading } = useR2Files() // Realtime changes append regardless of requested order. ISO timestamps // sort lexicographically, so this keeps new uploads first without parsing. const { records: rawAttachments, status } = useQuery('attachments') const attachments = useMemo( () => [...rawAttachments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)), [rawAttachments], ) const { create, remove } = useMutations('attachments') const filteredAttachments = useMemo(() => { if (filter === 'all') return attachments return attachments.filter(att => getFileCategory(att.data.mimeType, att.data.fileName) === filter) }, [attachments, filter, getFileCategory]) // Refs deduplicate loads across realtime array refreshes and retain every // created object URL for cleanup. const [previewUrls, setPreviewUrls] = useState>({}) const inflightKeysRef = useRef>(new Set()) const objectUrlsRef = useRef>(new Set()) const isMountedRef = useRef(false) // Free every object URL, including URLs added after the initial render. useEffect(() => { const objectUrls = objectUrlsRef.current const inflightKeys = inflightKeysRef.current isMountedRef.current = true return () => { isMountedRef.current = false for (const url of objectUrls) URL.revokeObjectURL(url) objectUrls.clear() inflightKeys.clear() } }, []) const loadPreviewUrl = useCallback(async (fileKey: string, mimeType: string) => { if (inflightKeysRef.current.has(fileKey)) return inflightKeysRef.current.add(fileKey) try { const response = await readFile(fileKey) const rawBlob = await response.blob() if (!isMountedRef.current) return // Preserve the detected type for native media and preview parsers. const blob = new Blob([rawBlob], { type: mimeType || rawBlob.type }) const url = URL.createObjectURL(blob) objectUrlsRef.current.add(url) setPreviewUrls(prev => ({ ...prev, [fileKey]: url })) } catch { // Preview not available — allow a retry on the next attachments push. inflightKeysRef.current.delete(fileKey) } }, [readFile]) // Native thumbnails need their bytes immediately. Heavier document formats // are fetched only when selected, rather than eagerly downloading every // attachment in the collection. useEffect(() => { filteredAttachments.forEach(att => { const category = getFileCategory(att.data.mimeType, att.data.fileName) if (category === 'image' || category === 'video' || category === 'audio') { void loadPreviewUrl(att.data.fileKey, att.data.mimeType) } }) if (previewAttachment) { const { fileKey, fileName, mimeType } = previewAttachment.data const category = getFileCategory(mimeType, fileName) if (canPreview(category)) void loadPreviewUrl(fileKey, mimeType) } }, [canPreview, filteredAttachments, getFileCategory, loadPreviewUrl, previewAttachment]) const handleUpload = async (file: File, detectedMime: string) => { const result = await upload(file, file.name) if (!result.success || !result.key) { toast.error(result.error ?? 'Upload failed') return false } // `ownerId` is intentionally omitted — the schema declares it // `userBound: true, immutable: true`, so the server fills it in // from the authenticated caller and ignores any client-supplied // value. The Attachment type still includes it (stored records // always carry it), hence the cast. await create({ fileName: file.name, fileKey: result.key, mimeType: detectedMime, fileSize: file.size, } as Attachment) toast.success('File uploaded') return true } const handleDeleteRequest = (recordId: string, fileKey: string, fileName: string) => { setDeleteTarget({ recordId, fileKey, fileName }) } const handleDeleteConfirm = async () => { if (!deleteTarget) return const result = await deleteFile(deleteTarget.fileKey) if (!result.success) { toast.error(result.error ?? 'Delete failed') return } await remove(deleteTarget.recordId) // Free the blob URL we created for this file and clear dedup state // so a future re-upload of the same key (rare but possible) can fetch. const url = previewUrls[deleteTarget.fileKey] if (url) { URL.revokeObjectURL(url) objectUrlsRef.current.delete(url) } inflightKeysRef.current.delete(deleteTarget.fileKey) setPreviewUrls(prev => { if (!(deleteTarget.fileKey in prev)) return prev const next = { ...prev } delete next[deleteTarget.fileKey] return next }) setDeleteTarget(null) toast.success('File deleted') } const handleDownload = async (fileKey: string, fileName: string) => { const result = await downloadFile(fileKey, fileName) if (!result.success) toast.error(result.error ?? 'Download failed') } const isLoading = status === 'loading' const categoryCounts = useMemo(() => { const counts: Record = { all: attachments.length } attachments.forEach(att => { const cat = getFileCategory(att.data.mimeType, att.data.fileName) counts[cat] = (counts[cat] ?? 0) + 1 }) return counts }, [attachments, getFileCategory]) return (
{/* Header */}

Attachments

{attachments.length} file{attachments.length !== 1 ? 's' : ''} uploaded

{canUpload && ( )}
{/* Filter badges */} {attachments.length > 0 && (
{Object.entries(CATEGORY_LABELS).map(([key, label]) => { const count = categoryCounts[key] ?? 0 if (key !== 'all' && count === 0) return null return ( ) })}
)}
{/* Content */}
{isLoading ? (
) : attachments.length === 0 ? ( } /> ) : filteredAttachments.length === 0 ? ( } /> ) : (
{filteredAttachments.map(att => { const isOwner = att.data.ownerId === user?.id const canDelete = isOwner || isAdmin const category = getFileCategory(att.data.mimeType, att.data.fileName) const url = previewUrls[att.data.fileKey] ?? null return (
{/* Thumbnail */}
setPreviewAttachment(att)}>
{/* Info */}

{att.data.fileName}

{category} {formatFileSize(att.data.fileSize)}
{canDelete && ( )}
) })}
)}
{/* Upload Modal */} setShowUploadModal(false)} onUpload={handleUpload} isUploading={isUploading} /> {/* Preview Modal */} setPreviewAttachment(null)} attachment={previewAttachment} previewUrl={previewAttachment ? (previewUrls[previewAttachment.data.fileKey] ?? null) : null} onDownload={() => { if (previewAttachment) { handleDownload(previewAttachment.data.fileKey, previewAttachment.data.fileName) } }} /> {/* Delete Confirmation Modal */} setDeleteTarget(null)} size="sm" data-testid="delete-confirm-modal"> Delete File

Are you sure you want to delete {deleteTarget?.fileName}? This cannot be undone.

) }