"use client"; import { type Dispatch, type DragEvent, type ReactNode, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { Loader2, AlertCircle, ChevronDown, ChevronRight, } from "lucide-react"; import { deleteDocument, getDocumentUrl, downloadDocumentsZip, listDocumentVersions, uploadDocumentVersion, replaceDocumentVersionFile, copyDocumentVersionFromDocument, deleteDocumentVersion, renameDocumentVersion, type DocumentVersion, } from "@/app/lib/mikeApi"; import type { Document, Folder as ProjectFolder, LibraryFolder, } from "@/app/components/shared/types"; import { closeRowActionMenus, RowActionMenuItems, RowActions, type RowActionMenuSurfaceProps, } from "@/app/components/shared/RowActions"; import { SubfolderSvgIcon, } from "@/app/components/shared/FolderSvgIcon"; import { useAuth } from "@/app/contexts/AuthContext"; import { WarningPopup } from "@/app/components/popups/WarningPopup"; import { UploadOverlay } from "@/app/components/assistant/UploadOverlay"; import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup"; import { formatUnsupportedDocumentWarning, partitionSupportedDocumentFiles, SUPPORTED_DOCUMENT_ACCEPT, } from "@/app/lib/documentUploadValidation"; import { DOC_NAME_COL_W, DocIcon, DocVersionHistory, formatBytes, formatDate, treeNameCellStyle, type ProjectContextMenu, } from "@/app/components/projects/ProjectPageParts"; import { DocumentSidePanel } from "@/app/components/shared/DocumentSidePanel"; import { LibrarySkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons"; import { APP_SURFACE_ACTIVE_CLASS, APP_SURFACE_GROUP_HOVER_CLASS, APP_SURFACE_HOVER_CLASS, } from "@/app/components/ui/liquid-surface"; import { TABLE_CHECKBOX_CLASS, TableFilters, TableHeaderCell, TableHeaderRow, TableScrollArea, TableStickyCell, type TableFilterOption, type TableSortDirection, } from "@/app/components/shared/TablePrimitive"; export type DocTableFolder = ProjectFolder | LibraryFolder; export interface DocTableSelectionActions { selectedCount: number; hasDocumentsInFolders: boolean; onDownload: () => Promise; onRemoveFromFolder: () => Promise; onDelete: () => Promise; } type DocumentSortKey = "name" | "size" | "version" | "created" | "updated"; const SORT_OPTIONS: TableFilterOption[] = [ { value: "asc", label: "Ascending" }, { value: "desc", label: "Descending" }, ]; interface DocTableOperations { uploadDocument: (file: File) => Promise; refreshCollection: () => Promise; createFolder: ( name: string, parentFolderId?: string | null, ) => Promise; renameFolder: ( folderId: string, name: string, ) => Promise; deleteFolder: (folderId: string) => Promise; moveFolder: ( folderId: string, parentFolderId: string | null, ) => Promise; moveDocument: ( documentId: string, folderId: string | null, ) => Promise; renameDocument: (documentId: string, filename: string) => Promise; } interface DocTableProps { scopeKey: string; documents: Document[]; setDocuments: Dispatch>; folders: DocTableFolder[]; setFolders: Dispatch>; loading: boolean; search: string; operations: DocTableOperations; emptyDropLabel?: string; renderAddDocumentsModal?: ( open: boolean, onClose: () => void, onSelect: (documents: Document[]) => void, ) => ReactNode; onAddDocumentsActionChange?: (action: (() => void) | null) => void; onCreateFolderActionChange?: (action: (() => void) | null) => void; onSelectionActionsChange?: (actions: DocTableSelectionActions | null) => void; onOwnerOnlyAction?: Dispatch>; enableHeaderFilters?: boolean; } function apiErrorDetail(error: unknown): string | null { if (!(error instanceof Error)) return null; try { const parsed = JSON.parse(error.message) as unknown; if ( parsed && typeof parsed === "object" && "detail" in parsed && typeof parsed.detail === "string" ) { return parsed.detail; } } catch { // Non-JSON errors can fall through to the plain message below. } return error.message || null; } function documentTypeValue(doc: Document): string { const explicit = doc.file_type?.trim(); if (explicit) return explicit.toLowerCase(); const extension = doc.filename.includes(".") ? doc.filename.split(".").pop()?.trim() : null; return (extension || "file").toLowerCase(); } function dateTimeValue(value: string | null | undefined): number { if (!value) return 0; const time = new Date(value).getTime(); return Number.isFinite(time) ? time : 0; } function documentVersionNumber(doc: Document): number | null { return doc.active_version_number ?? doc.latest_version_number ?? null; } function ProjectTableLoadingHeader({ stickyCellBg, }: { stickyCellBg: string; }) { return (
Name Type Size Version Created Updated ); } function ProjectTableLoading({ stickyCellBg }: { stickyCellBg: string }) { return (
{[1, 2, 3, 4, 5].map((i) => (
))}
); } export function DocTable({ scopeKey, documents, setDocuments, folders, setFolders, loading, search, operations, emptyDropLabel = "Drop PDF, Word, Excel, or PowerPoint files here", renderAddDocumentsModal, onAddDocumentsActionChange, onCreateFolderActionChange, onSelectionActionsChange, onOwnerOnlyAction, enableHeaderFilters = false, }: DocTableProps) { const [addDocsOpen, setAddDocsOpen] = useState(false); const { user } = useAuth(); const stickyCellBg = "bg-app-surface"; const [viewingDoc, setViewingDoc] = useState(null); const [viewingDocVersion, setViewingDocVersion] = useState<{ id: string; label: string; } | null>(null); const [selectedDocIds, setSelectedDocIds] = useState([]); const [typeFilter, setTypeFilter] = useState(null); const [sort, setSort] = useState<{ key: DocumentSortKey; direction: TableSortDirection; } | null>(null); const documentUploadInputRef = useRef(null); const loadingRef = useRef(loading); const renderAddDocumentsModalRef = useRef(renderAddDocumentsModal); const setOwnerOnlyAction = useMemo( () => onOwnerOnlyAction ?? (() => {}), [onOwnerOnlyAction], ); useEffect(() => { loadingRef.current = loading; renderAddDocumentsModalRef.current = renderAddDocumentsModal; }, [loading, renderAddDocumentsModal]); const openAddDocuments = useCallback(() => { if (loadingRef.current) return; if (renderAddDocumentsModalRef.current) { setAddDocsOpen(true); return; } documentUploadInputRef.current?.click(); }, []); useEffect(() => { onAddDocumentsActionChange?.(openAddDocuments); return () => onAddDocumentsActionChange?.(null); }, [onAddDocumentsActionChange, openAddDocuments]); // Version-history expansion (per-doc). versionsByDocId caches fetched // versions so toggling closed + open again doesn't refetch. loadingIds // drives the inline spinner in the version cell while a fetch is in // flight. const [expandedVersionDocIds, setExpandedVersionDocIds] = useState< Set >(() => new Set()); const [versionsByDocId, setVersionsByDocId] = useState< Map< string, { currentVersionId: string | null; versions: DocumentVersion[] } > >(() => new Map()); const [loadingVersionDocIds, setLoadingVersionDocIds] = useState< Set >(() => new Set()); const loadDocumentVersions = async ( docId: string, options: { expand?: boolean; force?: boolean } = {}, ) => { if (options.expand) { setExpandedVersionDocIds((prev) => new Set([...prev, docId])); } if (!options.force && versionsByDocId.has(docId)) return; setLoadingVersionDocIds((prev) => new Set([...prev, docId])); try { const res = await listDocumentVersions(docId); setVersionsByDocId((prev) => { const next = new Map(prev); next.set(docId, { currentVersionId: res.current_version_id, versions: res.versions, }); return next; }); } catch (e) { console.error("listDocumentVersions failed", e); } finally { setLoadingVersionDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); } }; const toggleVersions = async (docId: string) => { const already = expandedVersionDocIds.has(docId); if (already) { setExpandedVersionDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); return; } // Opening — expand immediately so the user sees a loading state. await loadDocumentVersions(docId, { expand: true }); }; async function downloadDocVersion( docId: string, versionId: string, filename: string, ) { try { const resolved = await getDocumentUrl(docId, versionId); const a = document.createElement("a"); a.href = resolved.url; // Prefer the backend's resolved filename (which honours the // version filename). Fall back to the passed filename // if for some reason it's missing. a.download = resolved.filename || filename; a.click(); } catch (e) { console.error("downloadDocVersion failed", e); } } function handleUploadNewVersion(doc: Document) { setVersionUploadTargetDoc(doc); window.setTimeout(() => versionUploadInputRef.current?.click(), 0); } async function handleVersionUploadInputChange( e: React.ChangeEvent, ) { const file = e.target.files?.[0] ?? null; e.target.value = ""; const doc = versionUploadTargetDoc; setVersionUploadTargetDoc(null); if (!file || !doc) return; await handleDropDocumentVersions(doc, [file]); } async function submitNewVersion( doc: Document, file: File, filename: string, ) { try { await uploadDocumentVersion(doc.id, file, filename); await refreshDocumentVersionState(doc.id); } catch (e) { console.error("uploadDocumentVersion failed", e); } } async function replaceVersionFile( docId: string, versionId: string, file: File, filename: string, ) { await replaceDocumentVersionFile(docId, versionId, file, filename); const res = await refreshDocumentVersionState(docId); const replaced = res.versions.find( (version) => version.id === versionId, ); if (replaced) { setViewingDocVersion({ id: replaced.id, label: replaced.filename?.trim() || "Version", }); } } async function refreshDocumentVersionState(docId: string) { // Refresh the collection so doc.active_version_number and filename advance. await operations.refreshCollection(); // Re-fetch versions while keeping the previous rows visible until the // updated list arrives. const res = await listDocumentVersions(docId); setVersionsByDocId((prev) => { const next = new Map(prev); next.set(docId, { currentVersionId: res.current_version_id, versions: res.versions, }); return next; }); return res; } /** * Patch a version filename and update the local cache in place. */ async function handleRenameVersion( docId: string, versionId: string, filename: string | null, ) { const previousFilename = versionsByDocId .get(docId) ?.versions.find((version) => version.id === versionId) ?.filename?.trim(); if ( previousFilename && (filename == null || hasFilenameExtensionChange(previousFilename, filename)) ) { setDocumentRenameWarning(extensionChangeWarning(previousFilename)); return; } try { const updated = await renameDocumentVersion( docId, versionId, filename, ); setVersionsByDocId((prev) => { const cached = prev.get(docId); if (!cached) return prev; const next = new Map(prev); next.set(docId, { ...cached, versions: cached.versions.map((v) => v.id === versionId ? updated : v, ), }); return next; }); } catch (e) { console.error("renameDocumentVersion failed", e); } } async function handleDeleteVersion(docId: string, versionId: string) { try { await deleteDocumentVersion(docId, versionId); const res = await refreshDocumentVersionState(docId); const activeVersions = res.versions.filter( (version) => version.deleted_at == null, ); const nextVersion = activeVersions.find( (version) => version.id === res.current_version_id, ) ?? activeVersions[activeVersions.length - 1] ?? null; setViewingDocVersion( nextVersion ? { id: nextVersion.id, label: nextVersion.filename?.trim() || "Version", } : null, ); } catch (e) { console.error("deleteDocumentVersion failed", e); setDocumentRenameWarning("Could not delete this version."); } } const [renamingDocumentId, setRenamingDocumentId] = useState( null, ); const [renameDocumentValue, setRenameDocumentValue] = useState(""); // Folder state const [expandedFolderIds, setExpandedFolderIds] = useState>( new Set(), ); // undefined = not creating; null = creating at root; string = creating inside that folder id const [creatingFolderIn, setCreatingFolderIn] = useState< string | null | undefined >(undefined); const [newFolderName, setNewFolderName] = useState(""); const [renamingFolderId, setRenamingFolderId] = useState( null, ); const [renameFolderValue, setRenameFolderValue] = useState(""); const [contextMenu, setContextMenu] = useState( null, ); const contextMenuRef = useRef(null); const newFolderInputRef = useRef(null); const versionUploadInputRef = useRef(null); const [dragOverFolderId, setDragOverFolderId] = useState( null, ); const [dragOverRoot, setDragOverRoot] = useState(false); const [dragOverFileRoot, setDragOverFileRoot] = useState(false); const [isDraggingCollectionFiles, setIsDraggingCollectionFiles] = useState(false); const collectionDragDepthRef = useRef(0); const [dragOverVersionDocId, setDragOverVersionDocId] = useState< string | null >(null); const [uploadingVersionDocIds, setUploadingVersionDocIds] = useState< Set >(() => new Set()); const [versionUploadTargetDoc, setVersionUploadTargetDoc] = useState(null); const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState< string[] >([]); const [deletingDocIds, setDeletingDocIds] = useState>( () => new Set(), ); const [documentUploadWarning, setDocumentUploadWarning] = useState< string | null >(null); const [documentRenameWarning, setDocumentRenameWarning] = useState< string | null >(null); const [collectionActionWarning, setCollectionActionWarning] = useState< string | null >(null); const [pendingVersionDrop, setPendingVersionDrop] = useState<{ targetDoc: Document; sourceDoc: Document; } | null>(null); const [pendingDeleteDoc, setPendingDeleteDoc] = useState( null, ); const [pendingDeleteStatus, setPendingDeleteStatus] = useState< "idle" | "deleting" | "deleted" >("idle"); const [pendingDeleteFolder, setPendingDeleteFolder] = useState<{ folder: DocTableFolder; folderIds: string[]; documentIds: string[]; documentCount: number; } | null>(null); const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState< "idle" | "deleting" | "deleted" >("idle"); const openCreateFolder = useCallback(() => { if (loadingRef.current) return; setCreatingFolderIn(null); setNewFolderName(""); }, []); useEffect(() => { onCreateFolderActionChange?.(openCreateFolder); return () => onCreateFolderActionChange?.(null); }, [onCreateFolderActionChange, openCreateFolder]); useEffect(() => { if (loading) return; setExpandedFolderIds(new Set(folders.map((f) => f.id))); }, [loading, folders]); useEffect(() => { setSelectedDocIds([]); setContextMenu(null); setTypeFilter(null); setSort(null); }, [scopeKey]); // Close context menu on outside click useEffect(() => { if (!contextMenu) return; function handle(e: MouseEvent) { if ( contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node) ) setContextMenu(null); } document.addEventListener("mousedown", handle); return () => document.removeEventListener("mousedown", handle); }, [contextMenu]); // Clear all drag state when any drag operation ends useEffect(() => { function handleDragEnd() { setDragOverFolderId(null); setDragOverRoot(false); setDragOverFileRoot(false); collectionDragDepthRef.current = 0; setIsDraggingCollectionFiles(false); } document.addEventListener("dragend", handleDragEnd); return () => document.removeEventListener("dragend", handleDragEnd); }, []); // Scroll new-folder input into view whenever it appears useEffect(() => { if (creatingFolderIn !== undefined) { newFolderInputRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", }); } }, [creatingFolderIn]); // ── Folder handlers ─────────────────────────────────────────────────────── function toggleFolder(id: string) { setExpandedFolderIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function handleCreateFolder(parentId: string | null) { const name = newFolderName.trim(); setNewFolderName(""); if (!name) { setCreatingFolderIn(undefined); return; } // Immediately hide the input and show an optimistic folder row setCreatingFolderIn(undefined); const tempId = `temp-${Date.now()}`; const optimistic = { id: tempId, user_id: "", name, parent_folder_id: parentId, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } as DocTableFolder; setFolders((prev) => [...prev, optimistic]); setExpandedFolderIds((prev) => new Set([...prev, tempId])); if (parentId) setExpandedFolderIds((prev) => new Set([...prev, parentId])); // Replace with real folder from API const folder = await operations.createFolder(name, parentId ?? null); setFolders((prev) => prev.map((f) => (f.id === tempId ? folder : f))); setExpandedFolderIds((prev) => { const next = new Set(prev); next.delete(tempId); next.add(folder.id); return next; }); } async function handleRenameFolder(folderId: string) { const name = renameFolderValue.trim(); setRenamingFolderId(null); if (!name) return; setFolders((prev) => prev.map((f) => (f.id === folderId ? { ...f, name } : f)), ); await operations.renameFolder(folderId, name); } function folderDeleteImpact(folderId: string) { const childrenByParent = new Map(); for (const folder of folders) { if (!folder.parent_folder_id) continue; const children = childrenByParent.get(folder.parent_folder_id) ?? []; children.push(folder.id); childrenByParent.set(folder.parent_folder_id, children); } const toDelete = new Set(); const stack = [folderId]; while (stack.length > 0) { const id = stack.pop(); if (!id || toDelete.has(id)) continue; toDelete.add(id); stack.push(...(childrenByParent.get(id) ?? [])); } const folderIds = [...toDelete]; const documentIds = documents .filter((d) => d.folder_id && toDelete.has(d.folder_id)) .map((d) => d.id); return { folderIds, documentIds, documentCount: documentIds.length }; } function requestDeleteFolder(folderId: string) { const folder = folders.find((f) => f.id === folderId); if (!folder) return; const impact = folderDeleteImpact(folderId); setPendingDeleteFolderStatus("idle"); setPendingDeleteFolder({ folder, folderIds: impact.folderIds, documentIds: impact.documentIds, documentCount: impact.documentCount, }); } async function confirmDeletePendingFolder() { const pending = pendingDeleteFolder; if (!pending || pendingDeleteFolderStatus === "deleting") return; setPendingDeleteFolderStatus("deleting"); try { await operations.deleteFolder(pending.folder.id); const toDelete = new Set(pending.folderIds); setFolders((prev) => prev.filter((f) => !toDelete.has(f.id))); setDocuments((prev) => prev.filter((d) => !d.folder_id || !toDelete.has(d.folder_id)), ); setExpandedFolderIds((prev) => { const next = new Set(prev); for (const id of toDelete) next.delete(id); return next; }); if (renamingFolderId && toDelete.has(renamingFolderId)) { setRenamingFolderId(null); } if (contextMenu?.folderId && toDelete.has(contextMenu.folderId)) { setContextMenu(null); } const deletedDocIds = new Set(pending.documentIds); setSelectedDocIds((prev) => prev.filter((id) => !deletedDocIds.has(id)), ); setExpandedVersionDocIds((prev) => { const next = new Set(prev); for (const id of pending.documentIds) next.delete(id); return next; }); setVersionsByDocId((prev) => { const next = new Map(prev); for (const id of pending.documentIds) next.delete(id); return next; }); setPendingDeleteFolderStatus("deleted"); window.setTimeout(() => { setPendingDeleteFolder(null); setPendingDeleteFolderStatus("idle"); }, 650); } catch (err) { console.error("delete folder failed", err); setPendingDeleteFolderStatus("idle"); setCollectionActionWarning( "Folder could not be deleted. Please try again.", ); } } // ── Doc/chat/review handlers ────────────────────────────────────────────── function handleDocsSelected(newDocs: Document[]) { setDocuments((prev) => [ ...prev, ...newDocs.filter((d) => !prev.some((e) => e.id === d.id)), ], ); } function removeDocumentFromLocalState(docId: string) { setDocuments((prev) => prev.filter((doc) => doc.id !== docId)); setSelectedDocIds((prev) => prev.filter((id) => id !== docId)); setExpandedVersionDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); setVersionsByDocId((prev) => { const next = new Map(prev); next.delete(docId); return next; }); setLoadingVersionDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); setUploadingVersionDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); setViewingDoc((prev) => (prev?.id === docId ? null : prev)); if (renamingDocumentId === docId) setRenamingDocumentId(null); if (contextMenu?.docId === docId) setContextMenu(null); } function restoreDocumentToLocalState( doc: Document, snapshot: { index: number; selected: boolean; versionsOpen: boolean; versions?: DocumentVersion[]; currentVersionId?: string | null; loadingVersions: boolean; uploadingVersion: boolean; viewing: boolean; viewingVersion: typeof viewingDocVersion; }, ) { setDocuments((prev) => { if (prev.some((d) => d.id === doc.id)) return prev; const nextDocs = [...prev]; nextDocs.splice( Math.max(0, Math.min(snapshot.index, nextDocs.length)), 0, doc, ); return nextDocs; }); if (snapshot.selected) { setSelectedDocIds((prev) => prev.includes(doc.id) ? prev : [...prev, doc.id], ); } if (snapshot.versionsOpen) { setExpandedVersionDocIds((prev) => new Set([...prev, doc.id])); } const versions = snapshot.versions; if (versions) { setVersionsByDocId((prev) => { const next = new Map(prev); next.set(doc.id, { currentVersionId: snapshot.currentVersionId ?? null, versions, }); return next; }); } if (snapshot.loadingVersions) { setLoadingVersionDocIds((prev) => new Set([...prev, doc.id])); } if (snapshot.uploadingVersion) { setUploadingVersionDocIds((prev) => new Set([...prev, doc.id])); } if (snapshot.viewing) { setViewingDoc(doc); setViewingDocVersion(snapshot.viewingVersion); } } async function handleRemoveDocFromFolder(docId: string) { setDocuments((prev) => prev.map((d) => d.id === docId ? { ...d, folder_id: null } : d, ), ); await operations.moveDocument(docId, null); } async function submitDocumentRename(docId: string) { const trimmed = renameDocumentValue.trim(); if (!trimmed) { setRenamingDocumentId(null); return; } const previous = documents.find((d) => d.id === docId); if (!previous || trimmed === previous.filename) { setRenamingDocumentId(null); return; } if (hasFilenameExtensionChange(previous.filename, trimmed)) { setDocumentRenameWarning(extensionChangeWarning(previous.filename)); return; } setRenamingDocumentId(null); setDocuments((prev) => prev.map((d) => d.id === docId ? { ...d, filename: trimmed, updated_at: new Date().toISOString(), } : d, ), ); try { const updated = await operations.renameDocument(docId, trimmed); setDocuments((prev) => prev.map((d) => (d.id === docId ? { ...d, ...updated } : d)), ); } catch (e) { console.error("renameDocument failed", e); setDocuments((prev) => previous ? prev.map((d) => (d.id === docId ? previous : d)) : prev, ); } } async function handleRemoveDoc(docId: string) { const doc = documents.find((d) => d.id === docId); // Backend only lets the doc creator delete. Warn the requester // instead of letting the request 404 silently. if (doc && user?.id && doc.user_id && doc.user_id !== user.id) { setOwnerOnlyAction("delete this document"); return; } setDeletingDocIds((prev) => new Set([...prev, docId])); try { await deleteDocument(docId); setDocuments((prev) => prev.filter((d) => d.id !== docId)); } finally { setDeletingDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); } } function requestRemoveDoc(doc: Document) { if (doc && user?.id && doc.user_id && doc.user_id !== user.id) { setOwnerOnlyAction("delete this document"); return; } const versionCount = versionsByDocId.get(doc.id)?.versions.length ?? currentVersionNumber(doc) ?? 1; if (versionCount <= 1) { void handleRemoveDoc(doc.id); return; } setPendingDeleteStatus("idle"); setPendingDeleteDoc(doc); } async function confirmRemovePendingDoc() { const pending = pendingDeleteDoc; if (!pending || pendingDeleteStatus === "deleting") return; setPendingDeleteStatus("deleting"); try { await handleRemoveDoc(pending.id); setPendingDeleteStatus("deleted"); window.setTimeout(() => { setPendingDeleteDoc(null); setPendingDeleteStatus("idle"); }, 650); } catch (err) { console.error("delete document failed", err); setPendingDeleteStatus("idle"); } } // ── Drag & drop ─────────────────────────────────────────────────────────── function wouldCreateCycle(movingId: string, targetId: string): boolean { // Returns true if targetId is movingId or a descendant of it let cur: DocTableFolder | undefined = folders.find( (f) => f.id === targetId, ); while (cur) { if (cur.id === movingId) return true; if (!cur.parent_folder_id) break; cur = folders.find((f) => f.id === cur!.parent_folder_id); } return false; } function hasMovePayload(dt: DataTransfer): boolean { return Array.from(dt.types).some( (type) => type === "application/mike-doc" || type === "application/mike-folder", ); } function hasFilePayload(dt: DataTransfer): boolean { return Array.from(dt.types).includes("Files"); } function hasDocumentPayload(dt: DataTransfer): boolean { return Array.from(dt.types).includes("application/mike-doc"); } function currentVersionNumber(doc: Document): number | null { return documentVersionNumber(doc); } function isSharedDocument(doc: Document | null | undefined): boolean { return !!(doc?.user_id && user?.id && doc.user_id !== user.id); } async function handleDropCollectionFiles(files: File[]) { if (files.length === 0) return; const { supported, unsupported } = partitionSupportedDocumentFiles(files); setDocumentUploadWarning(formatUnsupportedDocumentWarning(unsupported)); if (supported.length === 0) return; setUploadingDroppedFilenames(supported.map((file) => file.name)); try { const uploaded = await Promise.all( supported.map((file) => operations.uploadDocument(file)), ); handleDocsSelected(uploaded); } catch (err) { console.error("Document drop upload failed", err); } finally { setUploadingDroppedFilenames([]); } } useEffect(() => { const hasFiles = (dataTransfer: DataTransfer | null) => !!dataTransfer && Array.from(dataTransfer.types).includes("Files"); function handleDragEnter(event: globalThis.DragEvent) { if (!hasFiles(event.dataTransfer)) return; event.preventDefault(); collectionDragDepthRef.current += 1; setIsDraggingCollectionFiles(true); } function handleDragOver(event: globalThis.DragEvent) { if (!hasFiles(event.dataTransfer)) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; } function handleDragLeave(event: globalThis.DragEvent) { if (!hasFiles(event.dataTransfer)) return; collectionDragDepthRef.current = Math.max( 0, collectionDragDepthRef.current - 1, ); if (collectionDragDepthRef.current === 0) { setIsDraggingCollectionFiles(false); } } function handleDrop(event: globalThis.DragEvent) { if (!hasFiles(event.dataTransfer)) return; event.preventDefault(); event.stopPropagation(); collectionDragDepthRef.current = 0; setIsDraggingCollectionFiles(false); setDragOverFileRoot(false); void handleDropCollectionFiles( Array.from(event.dataTransfer?.files ?? []), ); } window.addEventListener("dragenter", handleDragEnter); window.addEventListener("dragover", handleDragOver); window.addEventListener("dragleave", handleDragLeave); window.addEventListener("drop", handleDrop); return () => { window.removeEventListener("dragenter", handleDragEnter); window.removeEventListener("dragover", handleDragOver); window.removeEventListener("dragleave", handleDragLeave); window.removeEventListener("drop", handleDrop); }; }); async function handleDropDocumentVersions(doc: Document, files: File[]) { if (files.length === 0) return; const { supported, unsupported } = partitionSupportedDocumentFiles(files); setDocumentUploadWarning(formatUnsupportedDocumentWarning(unsupported)); if (supported.length === 0) return; setUploadingVersionDocIds((prev) => new Set([...prev, doc.id])); try { for (const file of supported) { await uploadDocumentVersion(doc.id, file, file.name); } await refreshDocumentVersionState(doc.id); } catch (err) { console.error("Document version drop upload failed", err); } finally { setUploadingVersionDocIds((prev) => { const next = new Set(prev); next.delete(doc.id); return next; }); } } async function saveExistingDocumentAsNewVersion( targetDoc: Document, sourceDoc: Document, ) { const sourceIndex = documents.findIndex((doc) => doc.id === sourceDoc.id); const sourceSnapshot = { index: sourceIndex >= 0 ? sourceIndex : 0, selected: selectedDocIds.includes(sourceDoc.id), versionsOpen: expandedVersionDocIds.has(sourceDoc.id), versions: versionsByDocId.get(sourceDoc.id)?.versions, currentVersionId: versionsByDocId.get(sourceDoc.id) ?.currentVersionId, loadingVersions: loadingVersionDocIds.has(sourceDoc.id), uploadingVersion: uploadingVersionDocIds.has(sourceDoc.id), viewing: viewingDoc?.id === sourceDoc.id, viewingVersion: viewingDoc?.id === sourceDoc.id ? viewingDocVersion : null, }; setUploadingVersionDocIds((prev) => new Set([...prev, targetDoc.id])); removeDocumentFromLocalState(sourceDoc.id); try { await copyDocumentVersionFromDocument( targetDoc.id, sourceDoc.id, sourceDoc.filename, ); await refreshDocumentVersionState(targetDoc.id); } catch (err) { console.error("Existing document version drop failed", err); restoreDocumentToLocalState(sourceDoc, sourceSnapshot); setCollectionActionWarning( apiErrorDetail(err) ?? "Could not save this document as a new version.", ); } finally { setUploadingVersionDocIds((prev) => { const next = new Set(prev); next.delete(targetDoc.id); return next; }); } } function handleDropExistingDocumentVersion( targetDoc: Document, sourceDocId: string, ) { if (!sourceDocId || sourceDocId === targetDoc.id) return; const sourceDoc = documents.find((doc) => doc.id === sourceDocId); if (!sourceDoc) return; setPendingVersionDrop({ targetDoc, sourceDoc }); } function handleDocumentVersionDragOver( e: DragEvent, docId: string, ) { if ( !hasFilePayload(e.dataTransfer) && !hasDocumentPayload(e.dataTransfer) ) { return; } e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; setDragOverVersionDocId(docId); setDragOverFileRoot(false); setDragOverRoot(false); } function handleDocumentVersionDragLeave(e: DragEvent) { if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverVersionDocId(null); } } function handleDocumentVersionDrop( e: DragEvent, doc: Document, ) { if ( !hasFilePayload(e.dataTransfer) && !hasDocumentPayload(e.dataTransfer) ) { return; } e.preventDefault(); e.stopPropagation(); setDragOverVersionDocId(null); setDragOverFileRoot(false); collectionDragDepthRef.current = 0; setIsDraggingCollectionFiles(false); setDragOverRoot(false); setDragOverFolderId(null); if (hasFilePayload(e.dataTransfer)) { void handleDropDocumentVersions( doc, Array.from(e.dataTransfer.files), ); return; } void handleDropExistingDocumentVersion( doc, e.dataTransfer.getData("application/mike-doc"), ); } async function handleDropOnFolder( targetFolderId: string | null, dt: DataTransfer, ) { if (!hasMovePayload(dt)) return; const docId = dt.getData("application/mike-doc"); const subFolderId = dt.getData("application/mike-folder"); if (docId) { const doc = documents.find((d) => d.id === docId); if (!doc || (doc.folder_id ?? null) === targetFolderId) return; setDocuments((prev) => prev.map((d) => d.id === docId ? { ...d, folder_id: targetFolderId } : d, ), ); await operations.moveDocument(docId, targetFolderId); } else if (subFolderId && subFolderId !== targetFolderId) { if ( targetFolderId !== null && wouldCreateCycle(subFolderId, targetFolderId) ) return; const folder = folders.find((f) => f.id === subFolderId); if (!folder || (folder.parent_folder_id ?? null) === targetFolderId) return; setFolders((prev) => prev.map((f) => f.id === subFolderId ? { ...f, parent_folder_id: targetFolderId } : f, ), ); await operations.moveFolder(subFolderId, targetFolderId); } } // ── Tree rendering ──────────────────────────────────────────────────────── function renderFolderInput(parentId: string | null, depth: number) { if (creatingFolderIn !== parentId) return null; return (
setNewFolderName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void handleCreateFolder(parentId); if (e.key === "Escape") { setCreatingFolderIn(undefined); setNewFolderName(""); } }} onBlur={() => void handleCreateFolder(parentId)} />
); } function renderDocumentActivityRow({ key, filename, fileType, depth, statusLabel, }: { key: string; filename: string; fileType: string | null; depth: number; statusLabel: string; }) { return (
{filename}
{fileType ?? (filename.includes(".") ? filename.split(".").pop() : "file")}
{statusLabel}
); } function renderUploadingDocumentRows(depth: number) { return uploadingDroppedFilenames.map((filename) => renderDocumentActivityRow({ key: `uploading-doc-${filename}`, filename, fileType: null, depth, statusLabel: "Uploading", }), ); } function renderLevel(parentId: string | null, depth: number) { const nameMultiplier = enableHeaderFilters && sort?.key === "name" && sort.direction === "desc" ? -1 : 1; const childFolders = folders .filter((f) => f.parent_folder_id === parentId) .sort((a, b) => a.name.localeCompare(b.name) * nameMultiplier); const childDocs = filteredDocs.filter( (d) => (d.folder_id ?? null) === parentId, ); return ( <> {parentId === null && renderUploadingDocumentRows(depth)} {/* Files first */} {childDocs.map((doc) => { const docName = doc.filename; const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has(doc.id); const versionNumber = currentVersionNumber(doc); const hasVersions = typeof versionNumber === "number" && versionNumber > 1; const isVersionDragOver = dragOverVersionDocId === doc.id; const isUploadingVersion = uploadingVersionDocIds.has( doc.id, ); const isSelected = selectedDocIds.includes(doc.id); const isDeletingDoc = deletingDocIds.has(doc.id); if (isDeletingDoc) { return renderDocumentActivityRow({ key: `deleting-doc-${doc.id}`, filename: doc.filename, fileType: doc.file_type, depth, statusLabel: "Deleting...", }); } return (
{ if (renamingDocumentId === doc.id) { e.preventDefault(); return; } e.dataTransfer.setData( "application/mike-doc", doc.id, ); e.dataTransfer.effectAllowed = "copyMove"; }} onDragEnd={() => { setDragOverRoot(false); setDragOverFolderId(null); setDragOverVersionDocId(null); }} onDragOver={(e) => handleDocumentVersionDragOver(e, doc.id) } onDragLeave={handleDocumentVersionDragLeave} onDrop={(e) => handleDocumentVersionDrop(e, doc) } onClick={() => { setViewingDocVersion(null); setViewingDoc(doc); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); closeRowActionMenus(); setContextMenu({ x: e.clientX, y: e.clientY, docId: doc.id, folderId: null, showFolderActions: false, }); }} className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`} > {(() => { const rowBg = isVersionDragOver ? "bg-blue-50" : isSelected ? APP_SURFACE_ACTIVE_CLASS : stickyCellBg; return ( <>
{isProcessing || isUploadingVersion ? ( ) : ( setSelectedDocIds( (prev) => prev.includes( doc.id, ) ? prev.filter( ( x, ) => x !== doc.id, ) : [ ...prev, doc.id, ], ) } onClick={(e) => e.stopPropagation() } className="mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> )} {isError ? ( ) : ( )} {renamingDocumentId === doc.id ? ( e.stopPropagation() } onDragStart={( e, ) => { e.preventDefault(); e.stopPropagation(); }} onChange={(e) => setRenameDocumentValue( e.target .value, ) } onKeyDown={(e) => { if ( e.key === "Enter" ) void submitDocumentRename( doc.id, ); if ( e.key === "Escape" ) { setRenamingDocumentId( null, ); setRenameDocumentValue( "", ); } }} onBlur={() => void submitDocumentRename( doc.id, ) } /> ) : ( {docName} )}
{doc.file_type ?? ( )}
{doc.size_bytes != null ? ( formatBytes(doc.size_bytes) ) : ( )}
e.stopPropagation() } > {hasVersions ? ( ) : ( )}
{doc.created_at ? ( formatDate(doc.created_at) ) : ( )}
{doc.updated_at ? ( formatDate(doc.updated_at) ) : ( )}
{!isProcessing && ( { setRenameDocumentValue( docName, ); setRenamingDocumentId( doc.id, ); }} renameLabel="Rename document" onDownload={() => downloadDoc(doc.id) } onShowAllVersions={ hasVersions && !isVersionsOpen ? () => void toggleVersions( doc.id, ) : undefined } onUploadNewVersion={() => void handleUploadNewVersion( doc, ) } onRemoveFromFolder={ doc.folder_id ? () => handleRemoveDocFromFolder( doc.id, ) : undefined } onDelete={() => requestRemoveDoc( doc, ) } deleteDisabled={isSharedDocument( doc, )} /> )}
); })()}
{isVersionsOpen && ( { setViewingDocVersion({ id: versionId, label, }); setViewingDoc(doc); }} onRenameVersion={(versionId, filename) => handleRenameVersion( doc.id, versionId, filename, ) } onExtensionChangeBlocked={(filename) => setDocumentRenameWarning( extensionChangeWarning(filename), ) } /> )}
); })} {/* Subfolders after files, sorted alphabetically */} {childFolders.map((folder) => { const isExpanded = expandedFolderIds.has(folder.id); const isRenaming = renamingFolderId === folder.id; return (
{ if (isRenaming) { e.preventDefault(); return; } e.dataTransfer.setData( "application/mike-folder", folder.id, ); e.dataTransfer.effectAllowed = "move"; e.stopPropagation(); }} onDragOver={(e) => { if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); setDragOverFolderId(folder.id); setDragOverVersionDocId(null); }} onDragLeave={(e) => { e.stopPropagation(); setDragOverFolderId(null); }} onDrop={async (e) => { if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); setDragOverFolderId(null); setDragOverRoot(false); setDragOverVersionDocId(null); await handleDropOnFolder( folder.id, e.dataTransfer, ); }} onClick={() => toggleFolder(folder.id)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); closeRowActionMenus(); setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true, }); }} className={`group flex h-10 min-w-max items-center pr-8 ${APP_SURFACE_HOVER_CLASS} cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} >
{isExpanded ? ( ) : ( )} {isRenaming ? ( { e.preventDefault(); e.stopPropagation(); }} onChange={(e) => setRenameFolderValue( e.target.value, ) } onKeyDown={(e) => { if (e.key === "Enter") void handleRenameFolder( folder.id, ); if (e.key === "Escape") setRenamingFolderId( null, ); }} onBlur={() => void handleRenameFolder( folder.id, ) } onClick={(e) => e.stopPropagation() } /> ) : ( {folder.name} )}
e.stopPropagation()} > { setRenameFolderValue(folder.name); setRenamingFolderId(folder.id); }} onDelete={() => requestDeleteFolder(folder.id) } />
{isExpanded && renderLevel(folder.id, depth + 1)}
); })} {/* New-folder input row at the bottom of this level */} {renderFolderInput(parentId, depth)} ); } // ── Loading skeleton ────────────────────────────────────────────────────── const docs = documents; const downloadDoc = useCallback(async (docId: string) => { const { url, filename } = await getDocumentUrl(docId); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); }, []); const handleDownloadSelectedDocs = useCallback(async () => { const ids = [...selectedDocIds]; if (ids.length === 1) { await downloadDoc(ids[0]); return; } const blob = await downloadDocumentsZip(ids); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "documents.zip"; a.click(); URL.revokeObjectURL(a.href); }, [downloadDoc, selectedDocIds]); const handleRemoveSelectedFromFolder = useCallback(async () => { const ids = selectedDocIds.filter( (id) => docs.find((d) => d.id === id)?.folder_id != null, ); if (ids.length === 0) return; setDocuments((prev) => prev.map((d) => ids.includes(d.id) ? { ...d, folder_id: null } : d, ), ); await Promise.all( ids.map((id) => operations.moveDocument(id, null).catch(() => {})), ); }, [docs, operations, selectedDocIds, setDocuments]); const handleDeleteSelectedDocs = useCallback(async () => { const ids = [...selectedDocIds]; const owned = ids.filter((id) => { const doc = documents.find((candidate) => candidate.id === id); return !doc || !doc.user_id || !user?.id || doc.user_id === user.id; }); const blocked = ids.length - owned.length; setSelectedDocIds([]); const results = await Promise.allSettled( owned.map((id) => deleteDocument(id)), ); const deletedIds = owned.filter( (_, index) => results[index].status === "fulfilled", ); const failedCount = owned.length - deletedIds.length; setDocuments((prev) => prev.filter((doc) => !deletedIds.includes(doc.id)), ); if (deletedIds.length > 0) { setExpandedVersionDocIds((prev) => { const next = new Set(prev); for (const id of deletedIds) next.delete(id); return next; }); setVersionsByDocId((prev) => { const next = new Map(prev); for (const id of deletedIds) next.delete(id); return next; }); } if (failedCount > 0) { setCollectionActionWarning( `${failedCount} ${failedCount === 1 ? "document" : "documents"} could not be deleted. Please try again.`, ); } if (blocked > 0) { setOwnerOnlyAction( `delete ${blocked} of the selected documents — only the document creator can delete a document`, ); } }, [ documents, selectedDocIds, setDocuments, setOwnerOnlyAction, user?.id, ]); const sidePanelDoc = viewingDoc ? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc) : null; const versionUploadAccept = ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"; const q = search.toLowerCase(); const typeOptions = useMemo( () => Array.from(new Set(docs.map(documentTypeValue))) .sort((a, b) => a.localeCompare(b)) .map((type) => ({ value: type, label: type.toUpperCase(), })), [docs], ); function clearDocumentSelection() { setSelectedDocIds([]); } function handleTypeFilterChange(value: string | null) { setTypeFilter(value); clearDocumentSelection(); } function handleSortChange( key: DocumentSortKey, direction: TableSortDirection | null, ) { setSort(direction ? { key, direction } : null); clearDocumentSelection(); } const filteredDocs = useMemo(() => { const rows = docs .filter( (doc) => !q || doc.filename.toLowerCase().includes(q), ) .filter( (doc) => !enableHeaderFilters || !typeFilter || documentTypeValue(doc) === typeFilter, ); if (!enableHeaderFilters || !sort) return rows; return [...rows].sort((a, b) => { const multiplier = sort.direction === "asc" ? 1 : -1; if (sort.key === "size") { return ((a.size_bytes ?? 0) - (b.size_bytes ?? 0)) * multiplier; } if (sort.key === "version") { return ( ((documentVersionNumber(a) ?? 0) - (documentVersionNumber(b) ?? 0)) * multiplier ); } if (sort.key === "created") { return ( (dateTimeValue(a.created_at) - dateTimeValue(b.created_at)) * multiplier ); } if (sort.key === "updated") { return ( (dateTimeValue(a.updated_at) - dateTimeValue(b.updated_at)) * multiplier ); } return a.filename.localeCompare(b.filename) * multiplier; }); }, [docs, enableHeaderFilters, q, sort, typeFilter]); const nameSortDirection = sort?.key === "name" ? sort.direction : null; const sizeSortDirection = sort?.key === "size" ? sort.direction : null; const versionSortDirection = sort?.key === "version" ? sort.direction : null; const createdSortDirection = sort?.key === "created" ? sort.direction : null; const updatedSortDirection = sort?.key === "updated" ? sort.direction : null; const nameFilterButton = enableHeaderFilters ? ( handleSortChange("name", direction)} /> ) : null; const typeFilterButton = enableHeaderFilters ? ( ) : null; const sizeFilterButton = enableHeaderFilters ? ( handleSortChange("size", direction)} /> ) : null; const versionFilterButton = enableHeaderFilters ? ( handleSortChange("version", direction)} /> ) : null; const createdFilterButton = enableHeaderFilters ? ( handleSortChange("created", direction)} /> ) : null; const updatedFilterButton = enableHeaderFilters ? ( handleSortChange("updated", direction)} /> ) : null; const allDocsSelected = filteredDocs.length > 0 && filteredDocs.every((d) => selectedDocIds.includes(d.id)); const someDocsSelected = !allDocsSelected && filteredDocs.some((d) => selectedDocIds.includes(d.id)); const selectionActions = useMemo(() => { if (selectedDocIds.length === 0) return null; return { selectedCount: selectedDocIds.length, hasDocumentsInFolders: selectedDocIds.some( (id) => docs.find((d) => d.id === id)?.folder_id != null, ), onDownload: handleDownloadSelectedDocs, onRemoveFromFolder: handleRemoveSelectedFromFolder, onDelete: handleDeleteSelectedDocs, }; }, [ docs, handleDeleteSelectedDocs, handleDownloadSelectedDocs, handleRemoveSelectedFromFolder, selectedDocIds, ]); useEffect(() => { onSelectionActionsChange?.(selectionActions); }, [onSelectionActionsChange, selectionActions]); useEffect(() => { return () => onSelectionActionsChange?.(null); }, [onSelectionActionsChange]); const pendingVersionDropMessage = pendingVersionDrop ? (

You are about to save{" "} {pendingVersionDrop.sourceDoc.filename} {" "} as a new version of{" "} {pendingVersionDrop.targetDoc.filename} .

{pendingVersionDrop.sourceDoc.filename} {" "} will no longer exist as a separate document {(currentVersionNumber(pendingVersionDrop.sourceDoc) ?? 1) > 1 ? " and its older versions will be deleted" : ""} .

) : undefined; const pendingDeleteDocVersionCount = pendingDeleteDoc ? (versionsByDocId.get(pendingDeleteDoc.id)?.versions.length ?? currentVersionNumber(pendingDeleteDoc) ?? 1) : 0; const pendingDeleteDocMessage = pendingDeleteDoc ? (

{pendingDeleteDoc.filename} {" "} has {pendingDeleteDocVersionCount}{" "} {pendingDeleteDocVersionCount === 1 ? "version" : "versions"}. Deleting this document will delete all of its versions.

) : undefined; const pendingDeleteFolderMessage = pendingDeleteFolder ? (

This will permanently delete{" "} {pendingDeleteFolder.folderIds.length}{" "} {pendingDeleteFolder.folderIds.length === 1 ? "folder" : "folders"} , including{" "} {pendingDeleteFolder.folder.name} {pendingDeleteFolder.folderIds.length > 1 ? " and its nested subfolders" : ""} .

{pendingDeleteFolder.documentCount > 0 && (

{pendingDeleteFolder.documentCount}{" "} {pendingDeleteFolder.documentCount === 1 ? "document" : "documents"}{" "} in the deleted{" "} {pendingDeleteFolder.folderIds.length === 1 ? "folder" : "folders"}{" "} will also be permanently deleted.

)}
) : undefined; return (
{ const files = Array.from(event.target.files ?? []); event.target.value = ""; void handleDropCollectionFiles(files); }} /> setDocumentUploadWarning(null)} /> setDocumentRenameWarning(null)} message={documentRenameWarning} /> setCollectionActionWarning(null)} message={collectionActionWarning} /> setPendingVersionDrop(null)} onConfirm={() => { const pending = pendingVersionDrop; if (!pending) return; setPendingVersionDrop(null); void saveExistingDocumentAsNewVersion( pending.targetDoc, pending.sourceDoc, ); }} /> { if (pendingDeleteStatus === "deleting") return; setPendingDeleteDoc(null); setPendingDeleteStatus("idle"); }} onConfirm={() => void confirmRemovePendingDoc()} /> { if (pendingDeleteFolderStatus === "deleting") return; setPendingDeleteFolder(null); setPendingDeleteFolderStatus("idle"); }} onConfirm={() => void confirmDeletePendingFolder()} /> {/* Table content */} ) : ( { if (el) el.indeterminate = someDocsSelected; }} onChange={() => { if (allDocsSelected) setSelectedDocIds([]); else setSelectedDocIds( filteredDocs.map((d) => d.id), ); }} className={TABLE_CHECKBOX_CLASS} /> Name {nameFilterButton} Type {typeFilterButton} Size {sizeFilterButton} Version {versionFilterButton} Created {createdFilterButton} Updated {updatedFilterButton} ) } > {loading ? ( ) : (
{/* Blue ring wraps everything below the header when root-dropping */}
{ if (!hasFilePayload(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setDragOverFileRoot(true); setDragOverVersionDocId(null); }} onDragLeave={(e) => { if ( !e.currentTarget.contains( e.relatedTarget as Node, ) ) { setDragOverFileRoot(false); } }} onDrop={(e) => { if (!hasFilePayload(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); setDragOverFileRoot(false); collectionDragDepthRef.current = 0; setIsDraggingCollectionFiles(false); setDragOverRoot(false); setDragOverFolderId(null); setDragOverVersionDocId(null); void handleDropCollectionFiles( Array.from(e.dataTransfer.files), ); }} > {dragOverRoot && dragOverFolderId === null && (
)} {dragOverFileRoot && (
)} {/* Empty state */} {docs.length === 0 && folders.length === 0 && uploadingDroppedFilenames.length === 0 ? (

{emptyDropLabel}

) : (
{ e.preventDefault(); closeRowActionMenus(); setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false, }); }} onClick={() => setContextMenu(null)} onDragOver={(e) => { if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); setDragOverRoot(true); setDragOverVersionDocId(null); }} onDragLeave={(e) => { if ( !e.currentTarget.contains( e.relatedTarget as Node, ) ) { setDragOverRoot(false); } }} onDrop={async (e) => { if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); setDragOverRoot(false); setDragOverFolderId(null); setDragOverVersionDocId(null); await handleDropOnFolder( null, e.dataTransfer, ); }} > {/* Search: flat list; no search: folder tree */} {q ? ( <> {renderUploadingDocumentRows(0)} {filteredDocs.map((doc) => { const docName = doc.filename; const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has( doc.id, ); const versionNumber = currentVersionNumber( doc, ); const hasVersions = typeof versionNumber === "number" && versionNumber > 1; const isVersionDragOver = dragOverVersionDocId === doc.id; const isUploadingVersion = uploadingVersionDocIds.has( doc.id, ); const isSelected = selectedDocIds.includes( doc.id, ); const isDeletingDoc = deletingDocIds.has( doc.id, ); if (isDeletingDoc) { return renderDocumentActivityRow( { key: `deleting-doc-${doc.id}`, filename: doc.filename, fileType: doc.file_type, depth: 0, statusLabel: "Deleting...", }, ); } return (
{ if ( renamingDocumentId === doc.id ) { e.preventDefault(); return; } e.dataTransfer.setData( "application/mike-doc", doc.id, ); e.dataTransfer.effectAllowed = "copyMove"; }} onDragEnd={() => { setDragOverRoot( false, ); setDragOverFolderId( null, ); setDragOverVersionDocId( null, ); }} onDragOver={( e, ) => handleDocumentVersionDragOver( e, doc.id, ) } onDragLeave={ handleDocumentVersionDragLeave } onDrop={(e) => handleDocumentVersionDrop( e, doc, ) } onClick={() => { setViewingDocVersion( null, ); setViewingDoc( doc, ); }} onContextMenu={( e, ) => { e.preventDefault(); e.stopPropagation(); closeRowActionMenus(); setContextMenu( { x: e.clientX, y: e.clientY, docId: doc.id, folderId: null, showFolderActions: false, }, ); }} className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`} >
{isProcessing || isUploadingVersion ? ( ) : ( setSelectedDocIds( ( prev, ) => prev.includes( doc.id, ) ? prev.filter( ( x, ) => x !== doc.id, ) : [ ...prev, doc.id, ], ) } onClick={( e, ) => e.stopPropagation() } className="mr-4 h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black" /> )} {isError ? ( ) : ( )} {renamingDocumentId === doc.id ? ( e.stopPropagation() } onDragStart={( e, ) => { e.preventDefault(); e.stopPropagation(); }} onChange={( e, ) => setRenameDocumentValue( e .target .value, ) } onKeyDown={( e, ) => { if ( e.key === "Enter" ) void submitDocumentRename( doc.id, ); if ( e.key === "Escape" ) { setRenamingDocumentId( null, ); setRenameDocumentValue( "", ); } }} onBlur={() => void submitDocumentRename( doc.id, ) } /> ) : ( { docName } )}
{doc.file_type ?? ( )}
{doc.size_bytes != null ? ( formatBytes( doc.size_bytes, ) ) : ( )}
e.stopPropagation() } > {hasVersions ? ( ) : ( )}
{doc.created_at ? ( formatDate( doc.created_at, ) ) : ( )}
{doc.updated_at ? ( formatDate( doc.updated_at, ) ) : ( )}
{!isProcessing && ( { setRenameDocumentValue( docName, ); setRenamingDocumentId( doc.id, ); }} renameLabel="Rename document" onDownload={() => downloadDoc( doc.id, ) } onShowAllVersions={ hasVersions && !isVersionsOpen ? () => void toggleVersions( doc.id, ) : undefined } onUploadNewVersion={() => void handleUploadNewVersion( doc, ) } onDelete={() => requestRemoveDoc( doc, ) } deleteDisabled={isSharedDocument( doc, )} /> )}
{isVersionsOpen && ( { setViewingDocVersion( { id: versionId, label, }, ); setViewingDoc( doc, ); }} onRenameVersion={( versionId, filename, ) => handleRenameVersion( doc.id, versionId, filename, ) } onExtensionChangeBlocked={( filename, ) => setDocumentRenameWarning( extensionChangeWarning( filename, ), ) } /> )}
); })} ) : ( renderLevel(null, 0) )} {/* Spacer — fills remaining height and extends the root drop zone */}
)} {/* Context menu */} {contextMenu && (() => { const menuDoc = contextMenu.docId ? docs.find( (doc) => doc.id === contextMenu.docId, ) : null; const menuDocVersionNumber = menuDoc ? currentVersionNumber(menuDoc) : null; const menuDocHasVersions = typeof menuDocVersionNumber === "number" && menuDocVersionNumber > 1; const menuDocVersionsOpen = menuDoc ? expandedVersionDocIds.has( menuDoc.id, ) : false; const surfaceProps: RowActionMenuSurfaceProps = { className: "fixed z-[120]", style: { top: contextMenu.y, left: contextMenu.x, }, onClick: (e) => e.stopPropagation(), }; return createPortal( menuDoc ? ( setContextMenu(null) } onRename={() => { setRenameDocumentValue( menuDoc.filename, ); setRenamingDocumentId( menuDoc.id, ); }} renameLabel="Rename document" onDownload={() => downloadDoc(menuDoc.id) } onShowAllVersions={ menuDocHasVersions && !menuDocVersionsOpen ? () => void toggleVersions( menuDoc.id, ) : undefined } onUploadNewVersion={() => void handleUploadNewVersion( menuDoc, ) } onRemoveFromFolder={ menuDoc.folder_id ? () => void handleRemoveDocFromFolder( menuDoc.id, ) : undefined } onDelete={() => requestRemoveDoc(menuDoc) } deleteDisabled={isSharedDocument( menuDoc, )} /> ) : ( setContextMenu(null) } onNewSubfolder={() => { setCreatingFolderIn( contextMenu.folderId, ); setNewFolderName(""); if ( contextMenu.folderId ) { setExpandedFolderIds( (prev) => new Set([ ...prev, contextMenu.folderId!, ]), ); } }} newSubfolderLabel={ contextMenu.showFolderActions ? "New subfolder inside" : "New subfolder" } onRename={ contextMenu.showFolderActions && contextMenu.folderId ? () => { const f = folders.find( (x) => x.id === contextMenu.folderId, ); setRenameFolderValue( f?.name ?? "", ); setRenamingFolderId( contextMenu.folderId!, ); } : undefined } renameLabel="Rename folder" onDelete={ contextMenu.showFolderActions && contextMenu.folderId ? () => requestDeleteFolder( contextMenu.folderId!, ) : undefined } deleteLabel="Delete folder" /> ), document.body, ); })()}
{/* end blue ring wrapper */}
)} {renderAddDocumentsModal?.( addDocsOpen, () => setAddDocsOpen(false), handleDocsSelected, )} { setViewingDoc(null); setViewingDocVersion(null); }} onLoadVersions={(docId) => loadDocumentVersions(docId)} onSelectVersion={(versionId, label) => setViewingDocVersion({ id: versionId, label }) } onDownloadDocument={downloadDoc} onDownloadVersion={downloadDocVersion} onRenameVersion={handleRenameVersion} onDeleteVersion={handleDeleteVersion} onUploadNewVersion={submitNewVersion} onReplaceVersion={replaceVersionFile} canDelete={!isSharedDocument(sidePanelDoc)} onOwnerOnlyAction={setOwnerOnlyAction} onDelete={async (doc) => { await handleRemoveDoc(doc.id); }} />
); } function filenameExtension(filename: string) { const trimmed = filename.trim(); const dotIndex = trimmed.lastIndexOf("."); if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null; return trimmed.slice(dotIndex); } function hasFilenameExtensionChange(previous: string, next: string) { const previousExtension = filenameExtension(previous); if (previousExtension == null) return false; return ( filenameExtension(next)?.toLowerCase() !== previousExtension.toLowerCase() ); } function extensionChangeWarning(filename: string) { const extension = filenameExtension(filename); return extension ? `File extensions cannot be changed here. Keep ${extension} at the end of the name.` : "File extensions cannot be changed here."; }