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