"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Upload, Loader2, AlertCircle, ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, } from "lucide-react"; import { getProject, deleteDocument, createTabularReview, updateProject, listProjectChats, deleteChat, renameChat, listTabularReviews, deleteTabularReview, updateTabularReview, getDocumentUrl, downloadDocumentsZip, createProjectFolder, renameProjectFolder, deleteProjectFolder, moveDocumentToFolder, moveSubfolderToFolder, renameProjectDocument, listDocumentVersions, uploadDocumentVersion, uploadProjectDocument, renameDocumentVersion, getProjectPeople, type MikeDocumentVersion, } from "@/app/lib/mikeApi"; import type { MikeDocument, MikeFolder, MikeProject, MikeChat, TabularReview, } from "@/app/components/shared/types"; import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs"; import { closeRowActionMenus, RowActionMenuItems, RowActions, } from "@/app/components/shared/RowActions"; import { AddDocumentsModal, invalidateDirectoryCache, } from "@/app/components/shared/AddDocumentsModal"; import { PeopleModal } from "@/app/components/shared/PeopleModal"; import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal"; import { useAuth } from "@/contexts/AuthContext"; import { UploadNewVersionModal } from "@/app/components/shared/UploadNewVersionModal"; import { DocViewModal } from "@/app/components/shared/DocViewModal"; import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal"; import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; import { CHECK_W, DOC_NAME_COL_W, DocIcon, DocVersionHistory, formatBytes, formatDate, ProjectPageHeader, ProjectPageSkeleton, treeControlCellStyle, treeNameCellStyle, type ProjectContextMenu, type ProjectTab, } from "./ProjectPageParts"; import { ProjectAssistantTab } from "./ProjectAssistantTab"; import { ProjectReviewsTab } from "./ProjectReviewsTab"; interface Props { projectId: string; initialTab?: ProjectTab; } export function ProjectPage({ projectId, initialTab = "documents" }: Props) { const [project, setProject] = useState(null); const [folders, setFolders] = useState([]); const [chats, setChats] = useState([]); const [projectReviews, setProjectReviews] = useState([]); const [loading, setLoading] = useState(true); const searchParams = useSearchParams(); const tabParam = searchParams.get("tab"); const tab: ProjectTab = tabParam === "assistant" || tabParam === "reviews" ? tabParam : initialTab; const [addDocsOpen, setAddDocsOpen] = useState(false); const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); const { user } = useAuth(); const [uploadVersionDoc, setUploadVersionDoc] = useState(null); const [viewingDoc, setViewingDoc] = useState(null); const [viewingDocVersion, setViewingDocVersion] = useState<{ id: string; label: string; } | null>(null); const [creatingChat, setCreatingChat] = useState(false); const [creatingReview, setCreatingReview] = useState(false); const [newTRModalOpen, setNewTRModalOpen] = useState(false); // Per-tab selection const [selectedDocIds, setSelectedDocIds] = useState([]); const [selectedChatIds, setSelectedChatIds] = useState([]); const [selectedReviewIds, setSelectedReviewIds] = useState([]); // 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 >(() => new Map()); const [loadingVersionDocIds, setLoadingVersionDocIds] = useState< Set >(() => new Set()); 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. setExpandedVersionDocIds((prev) => new Set([...prev, docId])); if (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, res.versions); return next; }); } catch (e) { console.error("listDocumentVersions failed", e); } finally { setLoadingVersionDocIds((prev) => { const next = new Set(prev); next.delete(docId); return next; }); } }; 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's display_name). 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); } } /** * Trigger a file picker and upload the chosen file as a new version of * the given document. On success, refresh the project (for the doc's * latest_version_number) and re-fetch the version list so the history * panel shows the new row. */ function handleUploadNewVersion(doc: MikeDocument) { setUploadVersionDoc(doc); } async function submitNewVersion( doc: MikeDocument, file: File, displayName: string, ) { try { await uploadDocumentVersion(doc.id, file, displayName); // Refresh project so doc.latest_version_number and filename advance. const updated = await getProject(projectId); setProject(updated); // Re-fetch versions for this doc (invalidate cache first). setVersionsByDocId((prev) => { const next = new Map(prev); next.delete(doc.id); return next; }); // Ensure the history panel is expanded so the user sees it. setExpandedVersionDocIds((prev) => new Set([...prev, doc.id])); const res = await listDocumentVersions(doc.id); setVersionsByDocId((prev) => { const next = new Map(prev); next.set(doc.id, res.versions); return next; }); } catch (e) { console.error("uploadDocumentVersion failed", e); } } /** * Patch a version's display_name and update the local cache in place. */ async function handleRenameVersion( docId: string, versionId: string, displayName: string | null, ) { try { const updated = await renameDocumentVersion( docId, versionId, displayName, ); setVersionsByDocId((prev) => { const list = prev.get(docId); if (!list) return prev; const next = new Map(prev); next.set( docId, list.map((v) => (v.id === versionId ? updated : v)), ); return next; }); } catch (e) { console.error("renameDocumentVersion failed", e); } } // Inline rename for chats and reviews const [renamingChatId, setRenamingChatId] = useState(null); const [renameChatValue, setRenameChatValue] = useState(""); const [renamingReviewId, setRenamingReviewId] = useState(null); const [renameReviewValue, setRenameReviewValue] = useState(""); 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(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 [dragOverFolderId, setDragOverFolderId] = useState(null); const [dragOverRoot, setDragOverRoot] = useState(false); const [dragOverFileRoot, setDragOverFileRoot] = useState(false); const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState< string[] >([]); // Actions dropdown const [actionsOpen, setActionsOpen] = useState(false); const actionsRef = useRef(null); const [search, setSearch] = useState(""); const router = useRouter(); const { saveChat } = useChatHistoryContext(); function handleTabChange(newTab: ProjectTab) { const base = `/projects/${projectId}`; const url = newTab === "documents" ? base : `${base}?tab=${newTab}`; router.push(url); } useEffect(() => { Promise.all([ getProject(projectId), listProjectChats(projectId).catch(() => [] as MikeChat[]), listTabularReviews(projectId).catch(() => []), ]) .then(([proj, projectChats, projectReviews]) => { setProject(proj); const loadedFolders = proj.folders ?? []; setFolders(loadedFolders); setExpandedFolderIds(new Set(loadedFolders.map((f) => f.id))); setChats(projectChats); setProjectReviews(projectReviews); }) .finally(() => setLoading(false)); }, [projectId]); // Reset selection and close dropdowns when tab changes useEffect(() => { setSelectedDocIds([]); setSelectedChatIds([]); setSelectedReviewIds([]); setActionsOpen(false); setContextMenu(null); }, [tab]); useEffect(() => { function handleClick(e: MouseEvent) { if (actionsRef.current && !actionsRef.current.contains(e.target as Node)) setActionsOpen(false); } if (actionsOpen) document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [actionsOpen]); // 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); } 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); next.has(id) ? next.delete(id) : 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: MikeFolder = { id: tempId, project_id: projectId, user_id: "", name, parent_folder_id: parentId, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; 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 createProjectFolder(projectId, name, parentId ?? undefined); 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 renameProjectFolder(projectId, folderId, name); } async function handleDeleteFolder(folderId: string) { // Collect all subfolder IDs that will cascade-delete const toDelete = new Set(); function collectIds(id: string) { toDelete.add(id); folders.filter((f) => f.parent_folder_id === id).forEach((f) => collectIds(f.id)); } collectIds(folderId); setFolders((prev) => prev.filter((f) => !toDelete.has(f.id))); setProject((prev) => prev ? { ...prev, documents: (prev.documents ?? []).map((d) => d.folder_id && toDelete.has(d.folder_id) ? { ...d, folder_id: null } : d, ), } : prev, ); await deleteProjectFolder(projectId, folderId); } // ── Doc/chat/review handlers ────────────────────────────────────────────── function handleDocsSelected(newDocs: MikeDocument[]) { setProject((prev) => prev ? { ...prev, documents: [ ...(prev.documents || []), ...newDocs.filter((d) => !prev.documents?.some((e) => e.id === d.id)), ], } : prev, ); } async function handleRemoveDocFromFolder(docId: string) { setProject((prev) => prev ? { ...prev, documents: (prev.documents ?? []).map((d) => d.id === docId ? { ...d, folder_id: null } : d, ), } : prev); await moveDocumentToFolder(projectId, docId, null); } async function submitDocumentRename(docId: string) { const trimmed = renameDocumentValue.trim(); setRenamingDocumentId(null); if (!trimmed) return; const previous = project?.documents?.find((d) => d.id === docId); if (!previous || trimmed === previous.filename) return; setProject((prev) => prev ? { ...prev, documents: (prev.documents ?? []).map((d) => d.id === docId ? { ...d, filename: trimmed, updated_at: new Date().toISOString(), } : d, ), } : prev, ); try { const updated = await renameProjectDocument(projectId, docId, trimmed); setProject((prev) => prev ? { ...prev, documents: (prev.documents ?? []).map((d) => d.id === docId ? { ...d, ...updated } : d, ), } : prev, ); } catch (e) { console.error("renameProjectDocument failed", e); setProject((prev) => prev && previous ? { ...prev, documents: (prev.documents ?? []).map((d) => d.id === docId ? previous : d, ), } : prev, ); } } async function handleRemoveDoc(docId: string) { const doc = project?.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; } await deleteDocument(docId); setProject((prev) => prev ? { ...prev, documents: prev.documents?.filter((d) => d.id !== docId) || [] } : prev, ); } async function handleNewChat() { setCreatingChat(true); try { const id = await saveChat(projectId); if (id) router.push(`/projects/${projectId}/assistant/chat/${id}`); } finally { setCreatingChat(false); } } function handleNewReview() { const docs = project?.documents?.filter((d) => d.status === "ready") || []; if (docs.length === 0) return; setNewTRModalOpen(true); } async function handleCreateReview( title: string, _projectId?: string, documentIds?: string[], columnsConfig?: any, ) { setCreatingReview(true); try { const docs = project?.documents?.filter((d) => d.status === "ready") || []; const review = await createTabularReview({ title: title || undefined, document_ids: documentIds ?? docs.map((d) => d.id), columns_config: columnsConfig ?? [], project_id: projectId, }); router.push(`/projects/${projectId}/tabular-reviews/${review.id}`); } finally { setCreatingReview(false); } } async function handleTitleCommit(newName: string) { if (!newName || newName === project?.name) return; // Server-side this would 404 silently for non-owners; surface a // clear permission warning instead. if (project && project.is_owner === false) { setOwnerOnlyAction("rename this project"); return; } setProject((prev) => (prev ? { ...prev, name: newName } : prev)); await updateProject(projectId, { name: newName }); } async function submitChatRename(chatId: string) { const trimmed = renameChatValue.trim(); setRenamingChatId(null); if (!trimmed) return; const chat = chats.find((c) => c.id === chatId); if (chat && user?.id && chat.user_id !== user.id) { setOwnerOnlyAction("rename this chat"); return; } setChats((prev) => prev.map((c) => (c.id === chatId ? { ...c, title: trimmed } : c))); await renameChat(chatId, trimmed); } async function submitReviewRename(reviewId: string) { const trimmed = renameReviewValue.trim(); setRenamingReviewId(null); if (!trimmed) return; const review = projectReviews.find((r) => r.id === reviewId); if (review && user?.id && review.user_id !== user.id) { setOwnerOnlyAction("rename this tabular review"); return; } setProjectReviews((prev) => prev.map((r) => (r.id === reviewId ? { ...r, title: trimmed } : r))); await updateTabularReview(reviewId, { title: trimmed }); } async function downloadDoc(docId: string) { const { url, filename } = await getDocumentUrl(docId); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); } async function handleDownloadSelectedDocs() { setActionsOpen(false); 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); } async function handleRemoveSelectedFromFolder() { const ids = selectedDocIds.filter((id) => docs.find((d) => d.id === id)?.folder_id != null); setActionsOpen(false); if (ids.length === 0) return; setProject((prev) => prev ? { ...prev, documents: (prev.documents ?? []).map((d) => ids.includes(d.id) ? { ...d, folder_id: null } : d, ), } : prev); await Promise.all(ids.map((id) => moveDocumentToFolder(projectId, id, null).catch(() => {}))); } async function handleDeleteSelectedDocs() { const ids = [...selectedDocIds]; setActionsOpen(false); // Filter to docs the requester owns (server-side gate). const owned = ids.filter((id) => { const d = project?.documents?.find((dd) => dd.id === id); return !d || !d.user_id || !user?.id || d.user_id === user.id; }); const blocked = ids.length - owned.length; setSelectedDocIds([]); await Promise.all(owned.map((id) => deleteDocument(id).catch(() => {}))); setProject((prev) => prev ? { ...prev, documents: prev.documents?.filter((d) => !owned.includes(d.id)) || [] } : prev, ); if (blocked > 0) { setOwnerOnlyAction( `delete ${blocked} of the selected documents — only the document creator can delete a document`, ); } } async function handleDeleteSelectedChats() { const ids = [...selectedChatIds]; setActionsOpen(false); const owned = ids.filter((id) => { const c = chats.find((cc) => cc.id === id); return !c || !user?.id || c.user_id === user.id; }); const blocked = ids.length - owned.length; setSelectedChatIds([]); await Promise.all(owned.map((id) => deleteChat(id).catch(() => {}))); setChats((prev) => prev.filter((c) => !owned.includes(c.id))); if (blocked > 0) { setOwnerOnlyAction( `delete ${blocked} of the selected chats — only the chat creator can delete a chat`, ); } } async function handleDeleteSelectedReviews() { const ids = [...selectedReviewIds]; setActionsOpen(false); const owned = ids.filter((id) => { const r = projectReviews.find((rr) => rr.id === id); return !r || !user?.id || r.user_id === user.id; }); const blocked = ids.length - owned.length; setSelectedReviewIds([]); await Promise.all(owned.map((id) => deleteTabularReview(id).catch(() => {}))); setProjectReviews((prev) => prev.filter((r) => !owned.includes(r.id))); if (blocked > 0) { setOwnerOnlyAction( `delete ${blocked} of the selected reviews — only the review creator can delete a review`, ); } } async function handleDeleteChatRow(chat: MikeChat) { if (user?.id && chat.user_id !== user.id) { setOwnerOnlyAction("delete this chat"); return; } await deleteChat(chat.id); setChats((prev) => prev.filter((c) => c.id !== chat.id)); } async function handleDeleteReviewRow(review: TabularReview) { if (user?.id && review.user_id !== user.id) { setOwnerOnlyAction("delete this tabular review"); return; } await deleteTabularReview(review.id); setProjectReviews((prev) => prev.filter((r) => r.id !== review.id)); } // ── Drag & drop ─────────────────────────────────────────────────────────── function wouldCreateCycle(movingId: string, targetId: string): boolean { // Returns true if targetId is movingId or a descendant of it let cur: MikeFolder | 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"); } async function handleDropProjectFiles(files: File[]) { if (files.length === 0) return; setUploadingDroppedFilenames(files.map((file) => file.name)); try { const uploaded = await Promise.all( files.map((file) => uploadProjectDocument(projectId, file)), ); invalidateDirectoryCache(); handleDocsSelected(uploaded); } catch (err) { console.error("Project document drop upload failed", err); } finally { setUploadingDroppedFilenames([]); } } 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 = (project?.documents ?? []).find((d) => d.id === docId); if (!doc || (doc.folder_id ?? null) === targetFolderId) return; setProject((prev) => prev ? { ...prev, documents: (prev.documents ?? []).map((d) => d.id === docId ? { ...d, folder_id: targetFolderId } : d, ), } : prev); await moveDocumentToFolder(projectId, 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 moveSubfolderToFolder(projectId, 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 renderUploadingDocumentRows(depth: number) { return uploadingDroppedFilenames.map((filename) => (
{filename}
{filename.includes(".") ? filename.split(".").pop() : "file"}
Uploading
)); } function renderLevel(parentId: string | null, depth: number) { const childFolders = folders .filter((f) => f.parent_folder_id === parentId) .sort((a, b) => a.name.localeCompare(b.name)); const childDocs = (project?.documents ?? []).filter((d) => (d.folder_id ?? null) === parentId); return ( <> {parentId === null && renderUploadingDocumentRows(depth)} {/* Files first */} {childDocs.map((doc) => { const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has(doc.id); const hasVersions = typeof doc.latest_version_number === "number" && doc.latest_version_number >= 1; return (
{ if (renamingDocumentId === doc.id) { e.preventDefault(); return; } e.dataTransfer.setData("application/mike-doc", doc.id); e.dataTransfer.effectAllowed = "move"; }} 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 items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" > {(() => { const rowBg = selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"; return ( <>
e.stopPropagation()} > setSelectedDocIds((prev) => prev.includes(doc.id) ? prev.filter((x) => x !== doc.id) : [...prev, doc.id], ) } className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
{isProcessing ? ( ) : 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, ) } /> ) : ( {doc.filename} )}
{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(doc.filename); 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={() => handleRemoveDoc(doc.id)} /> )}
); })()}
{isVersionsOpen && ( { setViewingDocVersion({ id: versionId, label }); setViewingDoc(doc); }} onRenameVersion={(versionId, displayName) => handleRenameVersion(doc.id, versionId, displayName) } /> )}
); })} {/* 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); }} onDragLeave={(e) => { e.stopPropagation(); setDragOverFolderId(null); }} onDrop={async (e) => { if (!hasMovePayload(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); setDragOverFolderId(null); setDragOverRoot(false); 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 items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} >
{isExpanded ? : }
{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={() => handleDeleteFolder(folder.id)} />
{isExpanded && renderLevel(folder.id, depth + 1)}
); })} {/* New-folder input row at the bottom of this level */} {renderFolderInput(parentId, depth)} ); } // ── Loading skeleton ────────────────────────────────────────────────────── if (loading) return ; if (!project) { return (

Project not found

); } const docs = project.documents || []; const q = search.toLowerCase(); const filteredDocs = q ? docs.filter((d) => d.filename.toLowerCase().includes(q)) : docs; const filteredChats = q ? chats.filter((c) => (c.title ?? "").toLowerCase().includes(q)) : chats; const filteredReviews = q ? projectReviews.filter((r) => (r.title ?? "").toLowerCase().includes(q)) : projectReviews; const allDocsSelected = filteredDocs.length > 0 && filteredDocs.every((d) => selectedDocIds.includes(d.id)); const someDocsSelected = !allDocsSelected && filteredDocs.some((d) => selectedDocIds.includes(d.id)); const allChatsSelected = filteredChats.length > 0 && filteredChats.every((c) => selectedChatIds.includes(c.id)); const someChatsSelected = !allChatsSelected && filteredChats.some((c) => selectedChatIds.includes(c.id)); const allReviewsSelected = filteredReviews.length > 0 && filteredReviews.every((r) => selectedReviewIds.includes(r.id)); const someReviewsSelected = !allReviewsSelected && filteredReviews.some((r) => selectedReviewIds.includes(r.id)); const currentSelectionCount = tab === "documents" ? selectedDocIds.length : tab === "assistant" ? selectedChatIds.length : selectedReviewIds.length; const handleDeleteSelected = tab === "documents" ? handleDeleteSelectedDocs : tab === "assistant" ? handleDeleteSelectedChats : handleDeleteSelectedReviews; const actionsDropdown = currentSelectionCount > 0 ? (
{actionsOpen && (
{tab === "documents" && ( )} {tab === "documents" && selectedDocIds.some((id) => docs.find((d) => d.id === id)?.folder_id != null) && ( )}
)}
) : null; const toolbarActions = (
{actionsDropdown} {tab === "documents" && ( <> )}
); return (
router.push("/projects")} onOpenDocuments={() => router.push(`/projects/${projectId}`)} onTitleCommit={handleTitleCommit} onSearchChange={setSearch} onOpenPeople={() => setPeopleModalOpen(true)} onNewChat={handleNewChat} onNewReview={handleNewReview} /> {toolbarActions} } /> {/* Table content */}
{/* Tab: Documents */} {tab === "documents" && (
{/* Table header */}
{ if (el) el.indeterminate = someDocsSelected; }} onChange={() => { if (allDocsSelected) setSelectedDocIds([]); else setSelectedDocIds(filteredDocs.map((d) => d.id)); }} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
Name
Type
Size
Version
Created
Updated
{/* Blue ring wraps everything below the header when root-dropping */}
{ if (!hasFilePayload(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setDragOverFileRoot(true); }} 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); setDragOverRoot(false); setDragOverFolderId(null); void handleDropProjectFiles( Array.from(e.dataTransfer.files), ); }} > {dragOverRoot && dragOverFolderId === null && (
)} {dragOverFileRoot && (
)} {/* Empty state */} {docs.length === 0 && folders.length === 0 && uploadingDroppedFilenames.length === 0 ? (
setAddDocsOpen(true)} className="flex-1 flex cursor-pointer flex-col items-center justify-center py-24 text-center" >

Drop PDF or DOCX files here

) : (
{ 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); }} 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); await handleDropOnFolder(null, e.dataTransfer); }} > {/* Search: flat list; no search: folder tree */} {q ? ( <> {renderUploadingDocumentRows(0)} {filteredDocs.map((doc) => { const isProcessing = doc.status === "pending" || doc.status === "processing"; const isError = doc.status === "error"; const isVersionsOpen = expandedVersionDocIds.has(doc.id); const hasVersions = typeof doc.latest_version_number === "number" && doc.latest_version_number >= 1; return (
{ 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 items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" >
e.stopPropagation()}> setSelectedDocIds((prev) => prev.includes(doc.id) ? prev.filter((x) => x !== doc.id) : [...prev, doc.id])} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
{isProcessing ? : 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, ) } /> ) : ( {doc.filename} )}
{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(doc.filename); setRenamingDocumentId(doc.id); }} renameLabel="Rename document" onDownload={() => downloadDoc(doc.id)} onShowAllVersions={ hasVersions && !isVersionsOpen ? () => void toggleVersions(doc.id) : undefined } onUploadNewVersion={() => void handleUploadNewVersion(doc) } onDelete={() => handleRemoveDoc(doc.id)} /> )}
{isVersionsOpen && ( { setViewingDocVersion({ id: versionId, label }); setViewingDoc(doc); }} onRenameVersion={(versionId, displayName) => handleRenameVersion(doc.id, versionId, displayName) } /> )}
); })} ) : ( 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 menuDocHasVersions = typeof menuDoc?.latest_version_number === "number" && menuDoc.latest_version_number >= 1; const menuDocVersionsOpen = menuDoc ? expandedVersionDocIds.has(menuDoc.id) : false; return (
e.stopPropagation()} > {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={() => void handleRemoveDoc(menuDoc.id) } /> ) : ( 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 ? () => handleDeleteFolder( contextMenu.folderId!, ) : undefined } deleteLabel="Delete folder" /> )}
); })()}
{/* end blue ring wrapper */}
)} {/* Tab: Assistant */} {tab === "assistant" && ( router.push( `/projects/${projectId}/assistant/chat/${chatId}`, ) } onDeleteChat={handleDeleteChatRow} onOwnerOnlyAction={setOwnerOnlyAction} submitChatRename={submitChatRename} setSelectedChatIds={setSelectedChatIds} setRenamingChatId={setRenamingChatId} setRenameChatValue={setRenameChatValue} /> )} {/* Tab: Reviews */} {tab === "reviews" && ( router.push( `/projects/${projectId}/tabular-reviews/${reviewId}`, ) } onDeleteReview={handleDeleteReviewRow} onOwnerOnlyAction={setOwnerOnlyAction} submitReviewRename={submitReviewRename} setSelectedReviewIds={setSelectedReviewIds} setRenamingReviewId={setRenamingReviewId} setRenameReviewValue={setRenameReviewValue} /> )}
setAddDocsOpen(false)} onSelect={handleDocsSelected} breadcrumb={["Projects", project.name + (project.cm_number ? ` (${project.cm_number})` : ""), "Add Documents"]} projectId={projectId} /> setUploadVersionDoc(null)} onSubmit={(file, displayName) => submitNewVersion(uploadVersionDoc!, file, displayName) } /> { setViewingDoc(null); setViewingDocVersion(null); }} onDelete={(doc) => handleRemoveDoc(doc.id)} /> setNewTRModalOpen(false)} onAdd={handleCreateReview} projectDocs={project?.documents?.filter((d) => d.status === "ready")} projectName={project?.name} projectCmNumber={project?.cm_number} /> setOwnerOnlyAction(null)} /> setPeopleModalOpen(false)} resource={project} fetchPeople={getProjectPeople} currentUserEmail={user?.email ?? null} breadcrumb={[ "Projects", project ? project.name + (project.cm_number ? ` (${project.cm_number})` : "") : "", "People", ]} // Only owners may modify the member list. Without this prop // PeopleModal renders read-only — non-owners can still see // who has access but the add/remove controls are hidden. onSharedWithChange={ project.is_owner === false ? undefined : async (next) => { const updated = await updateProject(projectId, { shared_with: next, }); setProject((prev) => prev ? { ...prev, shared_with: updated.shared_with, } : prev, ); } } />
); }