"use client"; import { type CSSProperties, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Upload, Plus, Loader2, FileText, File, AlertCircle, ChevronDown, ChevronRight, Download, Folder, FolderOpen, FolderPlus, MessageSquare, Pencil, Table2, Users, } from "lucide-react"; import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn"; import { getProject, deleteDocument, createTabularReview, updateProject, listProjectChats, deleteChat, renameChat, listTabularReviews, deleteTabularReview, updateTabularReview, getDocumentUrl, downloadDocumentsZip, createProjectFolder, renameProjectFolder, deleteProjectFolder, moveDocumentToFolder, moveSubfolderToFolder, listDocumentVersions, uploadDocumentVersion, 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 { RenameableTitle } from "@/app/components/shared/RenameableTitle"; import { closeRowActionMenus, RowActionMenuItems, RowActions, } from "@/app/components/shared/RowActions"; import { AddDocumentsModal } 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"; interface Props { projectId: string; initialTab?: Tab; } type Tab = "documents" | "assistant" | "reviews"; type ContextMenu = { x: number; y: number; docId?: string | null; folderId: string | null; // null = right-clicked on root/empty space showFolderActions: boolean; // true when right-clicked on a specific folder row }; const CHECK_W = "w-8 shrink-0"; const NAME_COL_W = "w-[300px] shrink-0"; const TREE_CONTROL_WIDTH_PX = 32; const TREE_NAME_PADDING_PX = 8; function treeControlWidth(depth: number) { return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1); } function treeControlCellStyle(depth: number): CSSProperties | undefined { if (depth <= 0) return undefined; const width = treeControlWidth(depth); return { justifyContent: "flex-start", minWidth: width, paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX, width, }; } function treeNameCellStyle(depth: number): CSSProperties | undefined { if (depth <= 0) return undefined; return { left: treeControlWidth(depth) }; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatDate(iso: string) { return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric", }); } function DocIcon({ fileType }: { fileType: string | null }) { if (fileType === "pdf") return ; if (fileType === "docx" || fileType === "doc") return ; return ; } /** * Stacked rows rendered beneath a doc row when its Version column is * expanded. Each row shows a past (or current) version with its number, * source, date, and a download button that fetches that specific version. */ function DocVersionHistory({ docId, filename, loading, versions, depth = 0, onDownloadVersion, onOpenVersion, onRenameVersion, }: { docId: string; filename: string; loading: boolean; versions: MikeDocumentVersion[]; depth?: number; onDownloadVersion: ( docId: string, versionId: string, filename: string, ) => void; onOpenVersion?: ( versionId: string, versionLabel: string, ) => void; onRenameVersion?: ( versionId: string, displayName: string | null, ) => Promise | void; }) { const [editingVersionId, setEditingVersionId] = useState( null, ); const [editingValue, setEditingValue] = useState(""); const commit = async (versionId: string) => { const trimmed = editingValue.trim(); setEditingVersionId(null); // Empty string → clear override (falls back to V{n}) const next = trimmed.length > 0 ? trimmed : null; await onRenameVersion?.(versionId, next); }; if (loading && versions.length === 0) { return (
Loading versions…
); } if (versions.length === 0) { return (
No version history.
); } // Most recent version first. const ordered = [...versions].reverse(); return ( <> {ordered.map((v) => { const numberLabel = typeof v.version_number === "number" && v.version_number >= 1 ? `${v.version_number}` : v.source === "upload" ? "Original" : "—"; const displayLabel = v.display_name?.trim() || numberLabel; const dt = new Date(v.created_at); const dateLabel = Number.isNaN(dt.valueOf()) ? "" : dt.toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }); const isEditing = editingVersionId === v.id; return (
{ if (isEditing) return; onOpenVersion?.(v.id, displayLabel); }} className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors" >
{isEditing ? ( e.stopPropagation()} onChange={(e) => setEditingValue(e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void commit(v.id); } else if (e.key === "Escape") { setEditingVersionId(null); } }} onBlur={() => void commit(v.id)} className="min-w-0 flex-1 max-w-[240px] border-b border-gray-300 bg-transparent text-xs text-gray-800 outline-none focus:border-gray-500" /> ) : ( {displayLabel} )} {!isEditing && onRenameVersion && ( )} {dateLabel} · {v.source}
); })} ); } 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: Tab = 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(""); // 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); // Actions dropdown const [actionsOpen, setActionsOpen] = useState(false); const actionsRef = useRef(null); const [search, setSearch] = useState(""); const router = useRouter(); const { saveChat } = useChatHistoryContext(); function handleTabChange(newTab: Tab) { 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); } 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 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`, ); } } // ── 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; } async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) { 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 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 ( <> {/* 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 (
{ 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 ? ( ) : ( )} {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 && ( 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 (
{ e.dataTransfer.setData("application/mike-folder", folder.id); e.dataTransfer.effectAllowed = "move"; e.stopPropagation(); }} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); setDragOverFolderId(folder.id); }} onDragLeave={(e) => { e.stopPropagation(); setDragOverFolderId(null); }} onDrop={async (e) => { 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 select-none ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`} >
{isExpanded ? : }
{isExpanded ? : } {isRenaming ? ( 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 (
Projects
{[1, 2, 3, 4, 5].map((i) => (
))}
); } 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 (
{/* Page header */}
{tab !== "documents" ? ( ) : ( (#{project.cm_number}) : null} /> )} {tab !== "documents" && ( <> {tab === "assistant" ? "Assistant" : "Tabular Reviews"} )}
{docs.length === 0 && (
Upload a document first
)}
{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 */}
{dragOverRoot && dragOverFolderId === null && (
)} {/* Empty state */} {docs.length === 0 && folders.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) => { e.preventDefault(); setDragOverRoot(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverRoot(false); } }} onDrop={async (e) => { e.preventDefault(); setDragOverRoot(false); setDragOverFolderId(null); await handleDropOnFolder(null, e.dataTransfer); }} > {/* Search: flat list; no search: folder tree */} {q ? ( 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 ? : } {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 && ( 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)} 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" && ( <>
{ if (el) el.indeterminate = someChatsSelected; }} onChange={() => { if (allChatsSelected) setSelectedChatIds([]); else setSelectedChatIds(filteredChats.map((c) => c.id)); }} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
Chats
Created
{chats.length === 0 ? (

Assistant

Ask questions and get answers grounded in the documents in this project.

) : (
{filteredChats.map((chat) => (
{ if (renamingChatId === chat.id) return; router.push(`/projects/${projectId}/assistant/chat/${chat.id}`); }} className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" >
e.stopPropagation()}> setSelectedChatIds((prev) => prev.includes(chat.id) ? prev.filter((x) => x !== chat.id) : [...prev, chat.id])} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
{renamingChatId === chat.id ? ( setRenameChatValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitChatRename(chat.id); if (e.key === "Escape") setRenamingChatId(null); }} onBlur={() => submitChatRename(chat.id)} onClick={(e) => e.stopPropagation()} className="w-full text-sm text-gray-800 bg-transparent outline-none" /> ) : ( {chat.title ?? "Untitled Chat"} )}
{formatDate(chat.created_at)}
e.stopPropagation()}> { if (user?.id && chat.user_id !== user.id) { setOwnerOnlyAction("rename this chat"); return; } setRenameChatValue(chat.title ?? "Untitled Chat"); setRenamingChatId(chat.id); }} onDelete={async () => { 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)); }} />
))}
)} )} {/* Tab: Reviews */} {tab === "reviews" && ( <>
{ if (el) el.indeterminate = someReviewsSelected; }} onChange={() => { if (allReviewsSelected) setSelectedReviewIds([]); else setSelectedReviewIds(filteredReviews.map((r) => r.id)); }} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
Name
Columns
Documents
Created
{projectReviews.length === 0 ? (

Tabular Reviews

Extract data from project documents into tables using AI.

) : (
{filteredReviews.map((review) => (
{ if (renamingReviewId === review.id) return; router.push(`/projects/${projectId}/tabular-reviews/${review.id}`); }} className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors" >
e.stopPropagation()}> setSelectedReviewIds((prev) => prev.includes(review.id) ? prev.filter((x) => x !== review.id) : [...prev, review.id])} className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black" />
{renamingReviewId === review.id ? ( setRenameReviewValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitReviewRename(review.id); if (e.key === "Escape") setRenamingReviewId(null); }} onBlur={() => submitReviewRename(review.id)} onClick={(e) => e.stopPropagation()} className="w-full text-sm text-gray-800 bg-transparent outline-none" /> ) : ( {review.title ?? "Untitled Review"} )}
{review.columns_config?.length ?? 0}
{review.document_count ?? 0}
{review.created_at ? formatDate(review.created_at) : }
e.stopPropagation()}> { if (user?.id && review.user_id !== user.id) { setOwnerOnlyAction("rename this tabular review"); return; } setRenameReviewValue(review.title ?? "Untitled Review"); setRenamingReviewId(review.id); }} onDelete={async () => { 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)); }} />
))}
)} )}
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, ); } } />
); }