mike/frontend/src/app/components/projects/ProjectPage.tsx

1793 lines
89 KiB
TypeScript
Raw Normal View History

2026-04-29 19:49:06 +02:00
"use client";
2026-05-13 02:32:26 +08:00
import { useEffect, useRef, useState } from "react";
2026-04-29 19:49:06 +02:00
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,
2026-05-13 02:32:26 +08:00
renameProjectDocument,
2026-04-29 19:49:06 +02:00
listDocumentVersions,
uploadDocumentVersion,
uploadProjectDocument,
2026-04-29 19:49:06 +02:00
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";
2026-04-29 19:49:06 +02:00
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";
2026-05-13 02:32:26 +08:00
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";
2026-04-29 19:49:06 +02:00
interface Props {
projectId: string;
2026-05-13 02:32:26 +08:00
initialTab?: ProjectTab;
2026-04-29 19:49:06 +02:00
}
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
2026-04-29 19:49:06 +02:00
const [project, setProject] = useState<MikeProject | null>(null);
const [folders, setFolders] = useState<MikeFolder[]>([]);
const [chats, setChats] = useState<MikeChat[]>([]);
const [projectReviews, setProjectReviews] = useState<TabularReview[]>([]);
const [loading, setLoading] = useState(true);
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
2026-05-13 02:32:26 +08:00
const tab: ProjectTab =
2026-04-29 19:49:06 +02:00
tabParam === "assistant" || tabParam === "reviews"
? tabParam
: initialTab;
2026-04-29 19:49:06 +02:00
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const { user } = useAuth();
const [uploadVersionDoc, setUploadVersionDoc] =
useState<MikeDocument | null>(null);
const [viewingDoc, setViewingDoc] = useState<MikeDocument | null>(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<string[]>([]);
const [selectedChatIds, setSelectedChatIds] = useState<string[]>([]);
const [selectedReviewIds, setSelectedReviewIds] = useState<string[]>([]);
// 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<string>
>(() => new Set());
const [versionsByDocId, setVersionsByDocId] = useState<
Map<string, MikeDocumentVersion[]>
>(() => new Map());
const [loadingVersionDocIds, setLoadingVersionDocIds] = useState<
Set<string>
>(() => 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<string | null>(null);
const [renameChatValue, setRenameChatValue] = useState("");
const [renamingReviewId, setRenamingReviewId] = useState<string | null>(null);
const [renameReviewValue, setRenameReviewValue] = useState("");
2026-05-13 02:32:26 +08:00
const [renamingDocumentId, setRenamingDocumentId] = useState<string | null>(null);
const [renameDocumentValue, setRenameDocumentValue] = useState("");
2026-04-29 19:49:06 +02:00
// Folder state
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(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<string | null>(null);
const [renameFolderValue, setRenameFolderValue] = useState("");
2026-05-13 02:32:26 +08:00
const [contextMenu, setContextMenu] =
useState<ProjectContextMenu | null>(null);
2026-04-29 19:49:06 +02:00
const contextMenuRef = useRef<HTMLDivElement>(null);
const newFolderInputRef = useRef<HTMLDivElement | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [dragOverRoot, setDragOverRoot] = useState(false);
const [dragOverFileRoot, setDragOverFileRoot] = useState(false);
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
string[]
>([]);
2026-04-29 19:49:06 +02:00
// Actions dropdown
const [actionsOpen, setActionsOpen] = useState(false);
const actionsRef = useRef<HTMLDivElement>(null);
const [search, setSearch] = useState("");
const router = useRouter();
const { saveChat } = useChatHistoryContext();
2026-05-13 02:32:26 +08:00
function handleTabChange(newTab: ProjectTab) {
2026-04-29 19:49:06 +02:00
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);
2026-04-29 19:49:06 +02:00
}
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<string>();
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);
}
2026-05-13 02:32:26 +08:00
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,
);
}
}
2026-04-29 19:49:06 +02:00
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`,
);
}
}
2026-05-13 02:32:26 +08:00
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));
}
2026-04-29 19:49:06 +02:00
// ── 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;
}
2026-05-13 02:32:26 +08:00
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([]);
}
}
2026-04-29 19:49:06 +02:00
async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) {
2026-05-13 02:32:26 +08:00
if (!hasMovePayload(dt)) return;
2026-04-29 19:49:06 +02:00
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) {
2026-04-29 19:49:06 +02:00
if (creatingFolderIn !== parentId) return null;
return (
<div
ref={newFolderInputRef}
className="group flex items-center h-10 pr-8 border-b border-gray-50"
key={`new-folder-${parentId ?? "root"}`}
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`}
style={treeControlCellStyle(depth)}
>
<ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0" />
</div>
<div
2026-05-13 02:32:26 +08:00
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2`}
style={treeNameCellStyle(depth)}
>
2026-04-29 19:49:06 +02:00
<div className="flex items-center gap-1.5">
<FolderPlus className="h-4 w-4 text-amber-400 shrink-0" />
<input
autoFocus
className="flex-1 min-w-0 text-sm text-gray-800 bg-transparent outline-none border-b border-gray-300"
placeholder="Folder name"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateFolder(parentId);
if (e.key === "Escape") { setCreatingFolderIn(undefined); setNewFolderName(""); }
}}
onBlur={() => void handleCreateFolder(parentId)}
/>
</div>
</div>
<div className="ml-auto w-20 shrink-0" />
<div className="w-24 shrink-0" />
<div className="w-20 shrink-0" />
<div className="w-32 shrink-0" />
<div className="w-32 shrink-0" />
<div className="w-8 shrink-0" />
</div>
);
}
function renderUploadingDocumentRows(depth: number) {
return uploadingDroppedFilenames.map((filename) => (
<div
key={`uploading-doc-${filename}`}
className="group flex items-center h-10 pr-8 border-b border-gray-50"
>
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`}
style={treeControlCellStyle(depth)}
>
<input
type="checkbox"
disabled
className="h-2.5 w-2.5 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
/>
</div>
<div
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
<span className="text-sm text-gray-400 truncate">
{filename}
</span>
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300 uppercase truncate">
{filename.includes(".") ? filename.split(".").pop() : "file"}
</div>
<div className="w-24 shrink-0 text-sm text-gray-300">
Uploading
</div>
<div className="w-20 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-8 shrink-0" />
</div>
));
}
2026-04-29 19:49:06 +02:00
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)}
2026-04-29 19:49:06 +02:00
{/* 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 (
<div key={`doc-${doc.id}`}>
<div
2026-05-13 02:32:26 +08:00
draggable={renamingDocumentId !== doc.id}
2026-04-29 19:49:06 +02:00
onDragStart={(e) => {
2026-05-13 02:32:26 +08:00
if (renamingDocumentId === doc.id) {
e.preventDefault();
return;
}
2026-04-29 19:49:06 +02:00
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,
});
}}
2026-04-29 19:49:06 +02:00
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 (
<>
<div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
style={treeControlCellStyle(depth)}
2026-04-29 19:49:06 +02:00
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selectedDocIds.includes(doc.id)}
onChange={() =>
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"
/>
</div>
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
2026-04-29 19:49:06 +02:00
<div className="flex items-center gap-2">
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
) : isError ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<DocIcon fileType={doc.file_type} />
)}
2026-05-13 02:32:26 +08:00
{renamingDocumentId === doc.id ? (
<input
autoFocus
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none border-b border-gray-300"
value={renameDocumentValue}
onClick={(e) => 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,
)
}
/>
) : (
<span className="text-sm text-gray-800 truncate">{doc.filename}</span>
)}
2026-04-29 19:49:06 +02:00
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-500 uppercase truncate">
{doc.file_type ?? <span className="text-gray-300"></span>}
</div>
<div className="w-24 shrink-0 text-sm text-gray-500 truncate">
{doc.size_bytes != null ? formatBytes(doc.size_bytes) : <span className="text-gray-300"></span>}
</div>
<div
className="w-20 shrink-0 text-sm text-gray-500 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
{hasVersions ? (
<button
onClick={() => void toggleVersions(doc.id)}
className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-gray-100 transition-colors"
>
<span>{doc.latest_version_number}</span>
{isVersionsOpen ? (
<ChevronDown className="h-3 w-3 text-gray-400" />
) : (
<ChevronRight className="h-3 w-3 text-gray-400" />
)}
</button>
) : (
<span className="text-gray-300 pl-1"></span>
)}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
{doc.created_at ? formatDate(doc.created_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
{doc.updated_at ? formatDate(doc.updated_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-8 shrink-0 flex justify-end">
{!isProcessing && (
<RowActions
2026-05-13 02:32:26 +08:00
onRename={() => {
setRenameDocumentValue(doc.filename);
setRenamingDocumentId(doc.id);
}}
renameLabel="Rename document"
2026-04-29 19:49:06 +02:00
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)}
/>
)}
</div>
</>
);
})()}
</div>
{isVersionsOpen && (
<DocVersionHistory
docId={doc.id}
filename={doc.filename}
loading={loadingVersionDocIds.has(doc.id)}
versions={versionsByDocId.get(doc.id) ?? []}
depth={depth}
2026-04-29 19:49:06 +02:00
onDownloadVersion={downloadDocVersion}
onOpenVersion={(versionId, label) => {
setViewingDocVersion({ id: versionId, label });
setViewingDoc(doc);
}}
onRenameVersion={(versionId, displayName) =>
handleRenameVersion(doc.id, versionId, displayName)
}
/>
)}
</div>
);
})}
{/* Subfolders after files, sorted alphabetically */}
{childFolders.map((folder) => {
const isExpanded = expandedFolderIds.has(folder.id);
const isRenaming = renamingFolderId === folder.id;
return (
<div key={`folder-${folder.id}`}>
<div
2026-05-13 02:32:26 +08:00
draggable={!isRenaming}
2026-04-29 19:49:06 +02:00
onDragStart={(e) => {
2026-05-13 02:32:26 +08:00
if (isRenaming) {
e.preventDefault();
return;
}
2026-04-29 19:49:06 +02:00
e.dataTransfer.setData("application/mike-folder", folder.id);
e.dataTransfer.effectAllowed = "move";
e.stopPropagation();
}}
2026-05-13 02:32:26 +08:00
onDragOver={(e) => {
if (!hasMovePayload(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
setDragOverFolderId(folder.id);
}}
2026-04-29 19:49:06 +02:00
onDragLeave={(e) => { e.stopPropagation(); setDragOverFolderId(null); }}
2026-05-13 02:32:26 +08:00
onDrop={async (e) => {
if (!hasMovePayload(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
setDragOverFolderId(null);
setDragOverRoot(false);
await handleDropOnFolder(folder.id, e.dataTransfer);
}}
2026-04-29 19:49:06 +02:00
onClick={() => toggleFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
closeRowActionMenus();
2026-04-29 19:49:06 +02:00
setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true });
}}
2026-05-13 02:32:26 +08:00
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" : ""}`}
2026-04-29 19:49:06 +02:00
>
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50 self-stretch`} style={treeControlCellStyle(depth)}>
2026-04-29 19:49:06 +02:00
{isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-gray-400 shrink-0" />
}
</div>
2026-05-13 02:32:26 +08:00
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} p-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
2026-04-29 19:49:06 +02:00
<div className="flex items-center gap-1.5">
{isExpanded
? <FolderOpen className="h-4 w-4 text-amber-500 shrink-0" />
: <Folder className="h-4 w-4 text-amber-500 shrink-0" />
}
{isRenaming ? (
<input
autoFocus
className="flex-1 min-w-0 text-sm text-gray-800 bg-transparent outline-none"
value={renameFolderValue}
2026-05-13 02:32:26 +08:00
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
2026-04-29 19:49:06 +02:00
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()}
/>
) : (
<span className="text-sm text-gray-800 truncate">{folder.name}</span>
)}
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300"></div>
<div className="w-24 shrink-0 text-sm text-gray-300"></div>
<div className="w-20 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div className="w-32 shrink-0 text-sm text-gray-300"></div>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<RowActions
onRename={() => {
setRenameFolderValue(folder.name);
setRenamingFolderId(folder.id);
}}
onDelete={() => handleDeleteFolder(folder.id)}
/>
</div>
</div>
{isExpanded && renderLevel(folder.id, depth + 1)}
</div>
);
})}
{/* New-folder input row at the bottom of this level */}
{renderFolderInput(parentId, depth)}
2026-04-29 19:49:06 +02:00
</>
);
}
// ── Loading skeleton ──────────────────────────────────────────────────────
2026-05-13 02:32:26 +08:00
if (loading) return <ProjectPageSkeleton />;
2026-04-29 19:49:06 +02:00
if (!project) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-gray-400">Project not found</p>
</div>
);
}
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 ? (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-[120] overflow-hidden">
2026-04-29 19:49:06 +02:00
{tab === "documents" && (
<button
onClick={handleDownloadSelectedDocs}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
Download
</button>
)}
{tab === "documents" && selectedDocIds.some((id) => docs.find((d) => d.id === id)?.folder_id != null) && (
<button
onClick={handleRemoveSelectedFromFolder}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
Remove from subfolder
</button>
)}
<button
onClick={handleDeleteSelected}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
) : null;
const toolbarActions = (
<div className="flex items-center gap-5">
2026-04-29 19:49:06 +02:00
{actionsDropdown}
{tab === "documents" && (
<>
<button
onClick={() => { setCreatingFolderIn(null); setNewFolderName(""); }}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
2026-04-29 19:49:06 +02:00
>
<FolderPlus className="h-3.5 w-3.5" />
Add Subfolder
</button>
<button
onClick={() => setAddDocsOpen(true)}
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
2026-04-29 19:49:06 +02:00
>
<Upload className="h-3.5 w-3.5" />
Add Documents
</button>
</>
)}
</div>
);
return (
<div className="flex-1 overflow-y-auto bg-white flex flex-col h-full">
2026-05-13 02:32:26 +08:00
<ProjectPageHeader
project={project}
tab={tab}
search={search}
creatingChat={creatingChat}
creatingReview={creatingReview}
docsCount={docs.length}
onBackToProjects={() => router.push("/projects")}
onOpenDocuments={() => router.push(`/projects/${projectId}`)}
onTitleCommit={handleTitleCommit}
onSearchChange={setSearch}
onOpenPeople={() => setPeopleModalOpen(true)}
onNewChat={handleNewChat}
onNewReview={handleNewReview}
/>
2026-04-29 19:49:06 +02:00
<ToolbarTabs
tabs={[
{ id: "documents", label: "Documents" },
{ id: "assistant", label: "Assistant" },
{ id: "reviews", label: "Tabular Reviews" },
]}
active={tab}
onChange={handleTabChange}
actions={
<>
{toolbarActions}
</>
}
/>
{/* Table content */}
<div className="w-full flex-1 min-h-0 overflow-x-auto">
<div className="min-w-max flex min-h-full flex-col">
{/* Tab: Documents */}
{tab === "documents" && (
<div className="flex-1 flex flex-col min-h-0">
{/* Table header */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
<input
type="checkbox"
checked={allDocsSelected}
ref={(el) => { 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"
/>
</div>
2026-05-13 02:32:26 +08:00
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white pl-2 text-left`}>
2026-04-29 19:49:06 +02:00
Name
</div>
<div className="ml-auto w-20 shrink-0 text-left">Type</div>
<div className="w-24 shrink-0 text-left">Size</div>
<div className="w-20 shrink-0 text-left">Version</div>
<div className="w-32 shrink-0 text-left">Created</div>
<div className="w-32 shrink-0 text-left">Updated</div>
<div className="w-8 shrink-0" />
</div>
{/* Blue ring wraps everything below the header when root-dropping */}
<div
className="flex-1 flex flex-col min-h-0 relative"
onDragOver={(e) => {
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),
);
}}
>
2026-04-29 19:49:06 +02:00
{dragOverRoot && dragOverFolderId === null && (
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-[80]" />
2026-04-29 19:49:06 +02:00
)}
{dragOverFileRoot && (
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
)}
2026-04-29 19:49:06 +02:00
{/* Empty state */}
{docs.length === 0 &&
folders.length === 0 &&
uploadingDroppedFilenames.length === 0 ? (
2026-04-29 19:49:06 +02:00
<div
onClick={() => setAddDocsOpen(true)}
className="flex-1 flex cursor-pointer flex-col items-center justify-center py-24 text-center"
>
<Upload className="h-8 w-8 text-gray-200 mb-3" />
<p className="text-sm text-gray-400">Drop PDF or DOCX files here</p>
</div>
) : (
<div
className="flex-1 flex flex-col"
onContextMenu={(e) => {
e.preventDefault();
closeRowActionMenus();
2026-04-29 19:49:06 +02:00
setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false });
}}
onClick={() => setContextMenu(null)}
2026-05-13 02:32:26 +08:00
onDragOver={(e) => {
if (!hasMovePayload(e.dataTransfer)) return;
e.preventDefault();
setDragOverRoot(true);
}}
2026-04-29 19:49:06 +02:00
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragOverRoot(false);
}
}}
onDrop={async (e) => {
2026-05-13 02:32:26 +08:00
if (!hasMovePayload(e.dataTransfer)) return;
2026-04-29 19:49:06 +02:00
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 (
<div key={doc.id}>
2026-04-29 19:49:06 +02:00
<div
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,
});
}}
2026-04-29 19:49:06 +02:00
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
>
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedDocIds.includes(doc.id)}
onChange={() => 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"
/>
</div>
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
2026-04-29 19:49:06 +02:00
<div className="flex items-center gap-2">
{isProcessing ? <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> : isError ? <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> : <DocIcon fileType={doc.file_type} />}
2026-05-13 02:32:26 +08:00
{renamingDocumentId === doc.id ? (
<input
autoFocus
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none border-b border-gray-300"
value={renameDocumentValue}
onClick={(e) => 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,
)
}
/>
) : (
<span className="text-sm text-gray-800 truncate">{doc.filename}</span>
)}
2026-04-29 19:49:06 +02:00
</div>
</div>
<div className="ml-auto w-20 shrink-0 text-xs text-gray-500 uppercase truncate">{doc.file_type ?? <span className="text-gray-300"></span>}</div>
<div className="w-24 shrink-0 text-sm text-gray-500 truncate">{doc.size_bytes != null ? formatBytes(doc.size_bytes) : <span className="text-gray-300"></span>}</div>
<div
className="w-20 shrink-0 text-sm text-gray-500 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
{hasVersions ? (
<button
onClick={() => void toggleVersions(doc.id)}
className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-gray-100 transition-colors"
>
<span>{doc.latest_version_number}</span>
{isVersionsOpen ? (
<ChevronDown className="h-3 w-3 text-gray-400" />
) : (
<ChevronRight className="h-3 w-3 text-gray-400" />
)}
</button>
) : (
<span className="text-gray-300 pl-1"></span>
)}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
{doc.created_at ? formatDate(doc.created_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-32 shrink-0 text-sm text-gray-500 truncate">
{doc.updated_at ? formatDate(doc.updated_at) : <span className="text-gray-300"></span>}
</div>
<div className="w-8 shrink-0 flex justify-end">
{!isProcessing && (
<RowActions
2026-05-13 02:32:26 +08:00
onRename={() => {
setRenameDocumentValue(doc.filename);
setRenamingDocumentId(doc.id);
}}
renameLabel="Rename document"
2026-04-29 19:49:06 +02:00
onDownload={() => downloadDoc(doc.id)}
onShowAllVersions={
hasVersions && !isVersionsOpen
? () => void toggleVersions(doc.id)
: undefined
}
onUploadNewVersion={() =>
void handleUploadNewVersion(doc)
}
onDelete={() => handleRemoveDoc(doc.id)}
/>
)}
</div>
</div>
{isVersionsOpen && (
<DocVersionHistory
docId={doc.id}
filename={doc.filename}
loading={loadingVersionDocIds.has(doc.id)}
versions={versionsByDocId.get(doc.id) ?? []}
onDownloadVersion={downloadDocVersion}
onOpenVersion={(versionId, label) => {
setViewingDocVersion({ id: versionId, label });
setViewingDoc(doc);
}}
onRenameVersion={(versionId, displayName) =>
handleRenameVersion(doc.id, versionId, displayName)
}
/>
)}
</div>
);
})}
</>
2026-04-29 19:49:06 +02:00
) : (
renderLevel(null, 0)
)}
{/* Spacer — fills remaining height and extends the root drop zone */}
<div className="flex-1 min-h-16" />
</div>
)}
{/* 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 (
<div
ref={contextMenuRef}
className="fixed z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{menuDoc ? (
<RowActionMenuItems
onClose={() => setContextMenu(null)}
2026-05-13 02:32:26 +08:00
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)
}
/>
) : (
<RowActionMenuItems
onClose={() => 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"
/>
)}
</div>
);
})()}
2026-04-29 19:49:06 +02:00
</div>{/* end blue ring wrapper */}
</div>
)}
{/* Tab: Assistant */}
{tab === "assistant" && (
2026-05-13 02:32:26 +08:00
<ProjectAssistantTab
chats={chats}
filteredChats={filteredChats}
selectedChatIds={selectedChatIds}
allChatsSelected={allChatsSelected}
someChatsSelected={someChatsSelected}
renamingChatId={renamingChatId}
renameChatValue={renameChatValue}
currentUserId={user?.id}
onCreateChat={handleNewChat}
onOpenChat={(chatId) =>
router.push(
`/projects/${projectId}/assistant/chat/${chatId}`,
)
}
onDeleteChat={handleDeleteChatRow}
onOwnerOnlyAction={setOwnerOnlyAction}
submitChatRename={submitChatRename}
setSelectedChatIds={setSelectedChatIds}
setRenamingChatId={setRenamingChatId}
setRenameChatValue={setRenameChatValue}
/>
2026-04-29 19:49:06 +02:00
)}
{/* Tab: Reviews */}
{tab === "reviews" && (
2026-05-13 02:32:26 +08:00
<ProjectReviewsTab
docs={docs}
reviews={projectReviews}
filteredReviews={filteredReviews}
selectedReviewIds={selectedReviewIds}
allReviewsSelected={allReviewsSelected}
someReviewsSelected={someReviewsSelected}
renamingReviewId={renamingReviewId}
renameReviewValue={renameReviewValue}
creatingReview={creatingReview}
currentUserId={user?.id}
onCreateReview={handleNewReview}
onOpenReview={(reviewId) =>
router.push(
`/projects/${projectId}/tabular-reviews/${reviewId}`,
)
}
onDeleteReview={handleDeleteReviewRow}
onOwnerOnlyAction={setOwnerOnlyAction}
submitReviewRename={submitReviewRename}
setSelectedReviewIds={setSelectedReviewIds}
setRenamingReviewId={setRenamingReviewId}
setRenameReviewValue={setRenameReviewValue}
/>
2026-04-29 19:49:06 +02:00
)}
</div>
</div>
<AddDocumentsModal
open={addDocsOpen}
onClose={() => setAddDocsOpen(false)}
onSelect={handleDocsSelected}
breadcrumb={["Projects", project.name + (project.cm_number ? ` (${project.cm_number})` : ""), "Add Documents"]}
projectId={projectId}
/>
<UploadNewVersionModal
open={!!uploadVersionDoc}
doc={uploadVersionDoc}
onClose={() => setUploadVersionDoc(null)}
onSubmit={(file, displayName) =>
submitNewVersion(uploadVersionDoc!, file, displayName)
}
/>
<DocViewModal
doc={viewingDoc}
versionId={viewingDocVersion?.id ?? null}
versionLabel={viewingDocVersion?.label ?? null}
onClose={() => {
setViewingDoc(null);
setViewingDocVersion(null);
}}
onDelete={(doc) => handleRemoveDoc(doc.id)}
/>
<AddNewTRModal
open={newTRModalOpen}
onClose={() => setNewTRModalOpen(false)}
onAdd={handleCreateReview}
projectDocs={project?.documents?.filter((d) => d.status === "ready")}
projectName={project?.name}
projectCmNumber={project?.cm_number}
/>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
<PeopleModal
open={peopleModalOpen}
onClose={() => 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,
);
}
}
/>
</div>
);
}