refactor: Reorganize frontend by moving UI components, hooks, and utilities to new components, logic, features, and lib directories

This commit is contained in:
2025-12-04 23:05:35 +01:00
parent 128714a1e0
commit 8aa1872ea5
114 changed files with 199 additions and 199 deletions
@@ -0,0 +1,94 @@
import React, { type DragEvent } from 'react';
import type { DocumentsViewProps } from '../../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../../logic/useDocumentViewLogic';
interface UseFolderItemLogicProps extends DocumentsViewProps {
folder: any;
viewLogic: DocumentViewLogic;
}
export const useFolderItemLogic = (props: UseFolderItemLogicProps) => {
const {
folder,
viewLogic,
draggedFolderId,
onFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
} = props;
const {
selectedFolderIdsSet,
totalSelectionCount,
folderRename: {
editingId: editingFolderId,
draftValue: folderDraft,
setDraftValue: setFolderDraft,
beginEditing: beginFolderEditing,
cancelEditing: cancelFolderEditing,
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
},
} = viewLogic;
const canDragFolder = folder.id !== 'root';
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
const isFolderEditing = editingFolderId === folder.id;
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
const isFolderSaving = savingFolderId === folder.id;
const canSubmitFolder =
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
const handlers = {
onClick: (event: React.MouseEvent) => onFolderClick?.(folder, event),
onDoubleClick: (event: React.MouseEvent) => {
event.preventDefault();
onFolderSelect?.(folder.id);
},
onDragOver: (event: DragEvent<HTMLElement>) => onFolderDragOver?.(event, folder.id),
onDragLeave: onFolderDragLeave,
onDrop: (event: DragEvent<HTMLElement>) => onFolderDrop?.(event, folder.id),
onDragStart: (event: DragEvent<HTMLElement>) => {
if (canDragFolder) {
onFolderDragStart?.(event, folder.id);
}
},
onDragEnd: (event: DragEvent<HTMLElement>) => {
if (canDragFolder) {
onFolderDragEnd?.(event);
}
},
onRenameChange: setFolderDraft,
onRenameSubmit: () => submitFolderEditing(folder),
onRenameCancel: (event?: React.SyntheticEvent) => cancelFolderEditing(event),
onRenameBegin: (event: React.SyntheticEvent) => {
if (!allowInlineFolderEdit) return;
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
},
};
return {
canDragFolder,
isDraggingFolder,
isSelectedFolder,
isFolderEditing,
folderDraftValue,
isFolderSaving,
canSubmitFolder,
allowInlineFolderEdit,
attachFolderInputRef,
handlers,
};
};
@@ -0,0 +1,537 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getFolderTree, listFolderContents } from '../../../lib/api/apiClient';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { createRootNode, DEFAULT_FOLDER_NAME } from '../../../app/workspaceUtils';
import {
getEntryId,
isDocumentEntry,
isFolderEntry,
createDocumentEntryKey,
createFolderEntryKey,
} from '../../../app/entryKey';
import type { FolderId as FolderIdentifier, Identifier } from '../../../types/identifiers';
import type { Document } from '../../../types/documents';
type FolderId = FolderIdentifier | 'root';
interface FolderSummary {
id?: FolderId;
name?: string;
parent_id?: FolderId | null;
parentId?: FolderId | null;
children?: FolderId[];
subfolders?: FolderSummary[];
has_children?: boolean;
hasChildren?: boolean;
[key: string]: unknown;
}
interface FolderContentsEntry {
folder?: FolderSummary | null;
documents?: Document[];
subfolders?: FolderSummary[];
__includesDocuments?: boolean;
__sortField?: string | null;
__sortDirection?: string | null;
[key: string]: unknown;
}
interface FolderTreeNode extends FolderSummary {
id: FolderId;
children: FolderId[];
expanded?: boolean;
loaded?: boolean;
hasChildren?: boolean;
}
interface SelectionHelpers {
focusedDocumentId: Identifier | null;
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
}
interface UseFolderTreeOptions {
initialSelectedFolder?: FolderId;
tenantIdRef: MutableRefObject<Identifier | null>;
documentsSortFieldRef: MutableRefObject<string>;
documentsSortDirectionRef: MutableRefObject<string>;
selectionHelpers: SelectionHelpers;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContentsEntry>>>;
folderContentsRef: MutableRefObject<Map<FolderId, FolderContentsEntry>>;
}
interface FolderOption {
id: FolderId;
label: string;
}
const useFolderTree = ({
initialSelectedFolder = 'root',
tenantIdRef,
documentsSortFieldRef,
documentsSortDirectionRef,
selectionHelpers,
setDocuments,
setFolderContents,
folderContentsRef,
}: UseFolderTreeOptions) => {
const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
const rootNode = createRootNode() as FolderTreeNode;
return new Map([[rootNode.id, rootNode]]);
});
useEffect(() => {
const fetchTree = async () => {
try {
const data = await getFolderTree();
setFolderNodes((prev) => {
const next = new Map(prev);
const rootChildren: FolderId[] = [];
data.forEach((item) => {
const id = item.id as FolderId;
const parentId = (item.parent_id || 'root') as FolderId;
const children = (item.children || []).map((c) => c as FolderId);
next.set(id, {
id,
name: item.name,
parentId,
children,
expanded: false,
loaded: true,
hasChildren: children.length > 0,
});
if (parentId === 'root') {
rootChildren.push(id);
}
});
const root = next.get('root');
if (root) {
next.set('root', {
...(root as FolderTreeNode),
children: rootChildren,
hasChildren: rootChildren.length > 0,
loaded: true,
});
}
return next;
});
} catch (error) {
console.error('Failed to fetch folder tree', error);
}
};
fetchTree();
}, []);
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
const {
focusedDocumentId,
setFocusedDocumentId,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
} = selectionHelpers;
const focusedDocumentIdRef = useRef(focusedDocumentId);
useEffect(() => {
focusedDocumentIdRef.current = focusedDocumentId;
}, [focusedDocumentId]);
const applySelectedFolder = useCallback(
(folderId: FolderId, contents?: FolderContentsEntry | null) => {
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
const docs = Array.isArray(contents?.documents) ? contents.documents : [];
const folderInfo = contents?.folder ?? null;
setCurrentSubfolders(subfolders);
setDocuments(docs);
setCurrentFolder(folderInfo);
const availableDocKeys = docs
.map((doc) => createDocumentEntryKey(doc?.id as Identifier))
.filter(Boolean);
const availableDocKeySet = new Set(availableDocKeys);
const availableFolderKeys = new Set(
subfolders
.map((folder) => createFolderEntryKey(folder?.id as Identifier))
.filter(Boolean),
);
let nextDocKeys: string[] = [];
let mergedSelection: string[] = [];
setSelectedEntries((previous) => {
const previousFolderKeys = previous
.filter(isFolderEntry)
.filter((key) => availableFolderKeys.has(key));
const previousDocKeys = previous.filter(isDocumentEntry);
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
mergedSelection = [...previousFolderKeys, ...nextDocKeys];
return mergedSelection;
});
const nextFocus = (() => {
const currentFocusedId = focusedDocumentIdRef.current;
if (currentFocusedId) {
const currentFocusedKey = createDocumentEntryKey(currentFocusedId);
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
return currentFocusedId;
}
}
if (nextDocKeys.length) {
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
return getEntryId(lastDocKey) || null;
}
return null;
})();
setFocusedDocumentId(nextFocus);
const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null;
selectionAnchorRef.current = nextAnchor;
selectionOrderRef.current = mergedSelection;
setSelectionOrder(mergedSelection);
},
[
selectionAnchorRef,
selectionOrderRef,
setDocuments,
setFocusedDocumentId,
setSelectedEntries,
setSelectionOrder,
],
);
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
if (!targetId || targetId === 'root') {
return;
}
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map<FolderId, FolderTreeNode>(prev);
const node = next.get(targetId);
let currentId = node?.parentId ?? 'root';
let guard = 0;
while (currentId && guard < 32) {
guard += 1;
const currentNode = next.get(currentId);
if (!currentNode) break;
if (!currentNode.expanded) {
next.set(currentId, { ...currentNode, expanded: true });
}
currentId = currentNode.parentId ?? 'root';
}
return next;
});
}, []);
const ensureFolderData = useCallback(
async (
folderId: FolderId,
{
includeDocuments = true,
prefetchDepth = 0,
force = false,
sortField = documentsSortFieldRef.current,
sortDirection = documentsSortDirectionRef.current,
}: {
includeDocuments?: boolean;
prefetchDepth?: number;
force?: boolean;
sortField?: string;
sortDirection?: string;
} = {},
): Promise<FolderContentsEntry> => {
const requestTenantId = tenantIdRef.current;
const cached = folderContentsRef.current.get(folderId);
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
if (!force && cached) {
const includesDocuments = Boolean(cached.__includesDocuments);
if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
if (prefetchDepth > 0) {
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
await Promise.allSettled(
subfolders.map((entry) =>
ensureFolderData(entry.id, {
includeDocuments: false,
prefetchDepth: prefetchDepth - 1,
force: false,
}),
),
);
}
return cached;
}
}
const path = folderId === 'root' ? 'root' : folderId;
const params: Record<string, unknown> = {};
if (!includeDocuments) {
params.include_documents = false;
} else {
params.sort = sortField;
params.dir = sortDirection;
}
const data = await listFolderContents<FolderContentsEntry>(path, params);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
const childIds = childFolders
.map((child) => (child?.id ?? null) as FolderId | null)
.filter((id): id is FolderId => Boolean(id));
const enriched = {
...data,
__includesDocuments: includeDocuments,
__sortField: includeDocuments ? sortField : cachedSortField,
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
};
if (tenantIdRef.current !== requestTenantId) {
return enriched;
}
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map<FolderId, FolderTreeNode>(prev);
const existingNode = next.get(folderId) || {
id: folderId,
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
parentId: data.folder?.parent_id || 'root',
children: [],
expanded: folderId === 'root',
loaded: false,
hasChildren: false,
};
next.set(folderId, {
...existingNode,
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
children: childIds,
expanded: folderId === 'root' ? true : existingNode.expanded,
loaded: true,
hasChildren: childIds.length > 0,
});
childFolders.forEach((child) => {
const childId = (child?.id ?? null) as FolderId | null;
if (!childId) {
return;
}
const childNode = next.get(childId);
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
const childHasChildren = (() => {
if (childNode?.loaded) {
return previousChildren.length > 0;
}
if (Array.isArray(child?.subfolders)) {
return child.subfolders.length > 0;
}
const flag = [child?.has_children, child?.hasChildren, childNode?.hasChildren]
.find((value) => value != null);
return Boolean(flag);
})();
next.set(childId, {
id: childId,
name: child.name,
parentId: (child.parent_id ?? 'root') as FolderId,
children: previousChildren,
expanded: childNode?.expanded ?? false,
loaded: childNode?.loaded ?? false,
hasChildren: childHasChildren,
});
});
return next;
});
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
await Promise.allSettled(
childIds.map((childId) =>
ensureFolderData(childId, {
includeDocuments: false,
force: false,
prefetchDepth: prefetchDepth - 1,
}),
),
);
}
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map<FolderId, FolderContentsEntry>(prev);
if (includeDocuments) {
next.set(folderId, enriched);
} else {
const existingEntry = next.get(folderId);
if (existingEntry) {
next.set(folderId, {
...existingEntry,
...data,
documents: existingEntry.__includesDocuments
? existingEntry.documents
: data.documents,
__includesDocuments: existingEntry.__includesDocuments || false,
__sortField: existingEntry.__sortField ?? enriched.__sortField,
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
});
} else {
next.set(folderId, enriched);
}
}
return next;
});
return enriched;
},
[
documentsSortDirectionRef,
documentsSortFieldRef,
tenantIdRef,
setFolderContents,
folderContentsRef,
],
);
const ensureFolderAncestorsLoaded = useCallback(
async (targetId: FolderId | null) => {
if (!targetId || targetId === 'root') {
return;
}
let current = targetId;
let guard = 0;
while (current && current !== 'root' && guard < 32) {
guard += 1;
const node = folderNodes.get(current);
if (node?.loaded) {
current = node.parentId ?? 'root';
continue;
}
await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 });
current = folderNodes.get(current)?.parentId ?? 'root';
}
},
[folderNodes, ensureFolderData],
);
const isInvalidFolderDrop = useCallback(
(sourceId: FolderId | null, targetId: FolderId | null) => {
if (!sourceId) return false;
if (!targetId || targetId === 'root') {
return false;
}
if (sourceId === targetId) {
return true;
}
let current = targetId;
const visited = new Set();
while (current && current !== 'root' && !visited.has(current)) {
visited.add(current);
if (current === sourceId) {
return true;
}
const node = folderNodes.get(current);
if (!node) break;
current = node.parentId ?? 'root';
}
return false;
},
[folderNodes],
);
const resetFolderTreeState = useCallback(() => {
const rootNode = createRootNode() as FolderTreeNode;
setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
setFolderContents(new Map<FolderId, FolderContentsEntry>());
setSelectedFolder('root');
setCurrentFolder(null);
setCurrentSubfolders([]);
}, [setFolderContents]);
const currentFolderName = useMemo(() => {
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
return currentFolder.name;
}, [selectedFolder, currentFolder]);
const folderOptions: FolderOption[] = useMemo(() => {
const cache = new Map<FolderId, string>();
const computePath = (id: FolderId | null): string => {
if (cache.has(id as FolderId)) {
return cache.get(id as FolderId) as string;
}
if (!id || id === 'root') {
cache.set('root', DEFAULT_FOLDER_NAME);
return DEFAULT_FOLDER_NAME;
}
const node = folderNodes.get(id);
if (!node) {
return 'Folder';
}
const parentId = (node.parentId || 'root') as FolderId;
const parentPath = computePath(parentId);
const name = node.name || 'Folder';
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
cache.set(id, fullPath);
return fullPath;
};
const entries: FolderOption[] = [];
folderNodes.forEach((node, id) => {
if (!node) return;
entries.push({ id, label: computePath(id) });
});
entries.sort((a, b) => {
if (a.id === 'root') return -1;
if (b.id === 'root') return 1;
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
});
return entries;
}, [folderNodes]);
const folderLabelMap = useMemo(() => {
const map = new Map<FolderId, string>();
folderOptions.forEach((option) => {
map.set(option.id, option.label);
});
return map;
}, [folderOptions]);
return {
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
currentFolder,
setCurrentFolder,
currentSubfolders,
setCurrentSubfolders,
currentFolderName,
folderOptions,
folderLabelMap,
applySelectedFolder,
ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isInvalidFolderDrop,
resetFolderTreeState,
};
};
export default useFolderTree;
@@ -0,0 +1,688 @@
import { useCallback, useMemo } from 'react';
import type { DragEvent } from 'react';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../../app/workspaceUtils';
import {
createFolder,
deleteFolder,
moveFolder as moveFolderRequest,
renameFolder as renameFolderRequest,
} from '../../../lib/api/apiClient';
import type { FolderId } from '../../../types/identifiers';
import type { MessageOptions } from '../../../types/documents';
type FolderKey = FolderId | 'root';
interface FolderNode {
id: FolderKey;
name?: string;
parentId?: FolderKey | null;
children: FolderId[];
expanded?: boolean;
loaded?: boolean;
hasChildren?: boolean;
}
interface FolderContentsState {
folder?: { id: FolderKey; name?: string };
subfolders?: any[];
documents?: any[];
[key: string]: unknown;
}
interface EnsureFolderOptions {
force?: boolean;
includeDocuments?: boolean;
prefetchDepth?: number;
}
interface LoadFolderOptions {
preserveSearch?: boolean;
}
interface SelectFolderOptions {
replace?: boolean;
immediate?: boolean;
}
interface FolderClickHandlers {
onToggle: (folderId: FolderKey) => Promise<void>;
onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>;
onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>;
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void;
onDragLeave: (event: DragEvent<HTMLElement>) => void;
}
interface UseFolderTreeActionsOptions {
token?: string | null;
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
selectedFolder: FolderKey;
setSelectedFolder: (folderId: FolderKey) => void;
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
expandFolderAncestors: (folderId: FolderKey, options?: { includeSelf?: boolean }) => void;
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void;
setFolderContents: (
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
setCurrentFolder: (updater: (prev: any) => any) => void;
setSearchResultIds: (value: any) => void;
isFilterActive: boolean;
navigate?: (path: string, options?: { replace?: boolean }) => void;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
draggedDocumentIds: FolderId[];
draggedFolderId: FolderKey | null;
setDraggedDocumentIds: (ids: FolderId[]) => void;
setDraggedFolderId: (id: FolderKey | null) => void;
isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean;
setCreatingFolder: (value: boolean) => void;
}
const useFolderTreeActions = ({
token,
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
applySelectedFolder,
notifyApiError,
setStatusMessage,
setFolderContents,
setCurrentFolder,
setSearchResultIds,
isFilterActive,
navigate,
handleFileDrop,
moveDocumentsToFolder,
draggedDocumentIds,
draggedFolderId,
setDraggedDocumentIds,
setDraggedFolderId,
isInvalidFolderDrop,
setCreatingFolder,
}: UseFolderTreeActionsOptions) => {
const moveFolder = useCallback(
async (folderId: FolderKey, targetFolderId: FolderKey | null) => {
const node = folderNodes.get(folderId);
if (!node) {
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
return;
}
const previousParentKey = node.parentId ?? 'root';
const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root';
if (previousParentKey === targetKey) {
return;
}
const parent_id = targetKey === 'root' ? null : targetKey;
try {
await moveFolderRequest(folderId, parent_id);
setFolderNodes((prev) => {
const next = new Map(prev);
const currentNode = next.get(folderId);
if (!currentNode) {
return prev;
}
next.set(folderId, { ...currentNode, parentId: parent_id ?? null });
const previousParent = next.get(previousParentKey);
if (previousParent) {
const remainingChildren = (previousParent.children || []).filter(
(childId) => childId !== folderId,
);
next.set(previousParentKey, {
...previousParent,
children: remainingChildren,
hasChildren: remainingChildren.length > 0,
});
}
if (!next.has(targetKey)) {
next.set(targetKey, {
id: targetKey,
name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder',
parentId: targetKey === 'root' ? null : null,
children: [],
expanded: targetKey === 'root',
loaded: false,
hasChildren: false,
});
}
const targetNode = next.get(targetKey);
if (targetNode && !targetNode.children.includes(folderId)) {
next.set(targetKey, {
...targetNode,
children: [...targetNode.children, folderId],
hasChildren: true,
});
}
return next;
});
const refreshTargets = new Set([previousParentKey, targetKey]);
await Promise.all(
Array.from(refreshTargets).map((key) =>
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
),
);
if (selectedFolder === folderId) {
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
setSelectedFolder(folderId);
}
setStatusMessage('Folder moved.', 'success');
} catch (error) {
const message = error.response?.data?.error || 'Failed to move folder.';
notifyApiError(error, message);
const refreshTargets = new Set([previousParentKey, targetKey]);
await Promise.all(
Array.from(refreshTargets).map((key) =>
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
),
);
}
},
[
ensureFolderData,
folderNodes,
notifyApiError,
selectedFolder,
setFolderNodes,
setSelectedFolder,
setStatusMessage,
],
);
const loadFolder = useCallback(
async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root';
setSelectedFolder(targetId);
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
try {
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
if (targetId !== 'root') {
try {
await ensureFolderData('root', {
force: false,
includeDocuments: false,
prefetchDepth: 1,
});
} catch (error) {
console.warn('Failed to refresh root folder tree', error);
}
}
applySelectedFolder(targetId, contents);
if (!preserveSearch) {
setSearchResultIds(null);
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
}
},
[
applySelectedFolder,
ensureFolderAncestorsLoaded,
ensureFolderData,
expandFolderAncestors,
notifyApiError,
setSearchResultIds,
setSelectedFolder,
],
);
const selectFolder = useCallback(
async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
if (!navigate || immediate) {
await loadFolder(targetId, { preserveSearch: isFilterActive });
setSelectedFolder(targetId);
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace });
},
[
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isFilterActive,
loadFolder,
navigate,
setSelectedFolder,
],
);
const handleFolderRename = useCallback(
async (folderId: FolderKey, nextName: string) => {
if (!token) {
setStatusMessage('Log in to rename folders.', 'error');
return false;
}
const trimmed = nextName?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
try {
await renameFolderRequest(folderId, trimmed);
setFolderNodes((prev) => {
const next = new Map(prev);
const node = next.get(folderId);
if (node) {
next.set(folderId, { ...node, name: trimmed });
}
return next;
});
setFolderContents((prev) => {
if (!prev.has(folderId)) {
return prev;
}
const next = new Map(prev);
const existing = next.get(folderId) || {};
const folderInfo = existing.folder
? { ...existing.folder, name: trimmed }
: { id: folderId, name: trimmed };
next.set(folderId, { ...existing, folder: folderInfo });
return next;
});
setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev));
setStatusMessage('Folder renamed.', 'success');
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to rename folder.';
notifyApiError(error, message);
return false;
}
},
[
notifyApiError,
setCurrentFolder,
setFolderContents,
setFolderNodes,
setStatusMessage,
token,
],
);
const handleFolderCreate = useCallback(
async (name: string, parentId?: FolderKey | null) => {
if (!token) {
setStatusMessage('Log in to create folders.', 'error');
return false;
}
if (!name.trim()) {
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
const targetParentId = parentId !== undefined
? (parentId === 'root' ? null : parentId)
: (selectedFolder === 'root' ? null : selectedFolder);
const payload = {
name: name.trim(),
parent_id: targetParentId,
};
setCreatingFolder(true);
let succeeded = false;
try {
const data = await createFolder(payload);
const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder;
if (!folderData?.id) {
throw new Error('Folder creation failed.');
}
setStatusMessage('Folder created.', 'success');
setFolderNodes((prev) => {
const next = new Map(prev);
const parentId = folderData.parent_id ?? payload.parent_id ?? 'root';
const parentNode = next.get(parentId);
if (parentNode) {
next.set(parentId, {
...parentNode,
children: parentNode.children.concat([folderData.id]),
loaded: true,
hasChildren: true,
});
}
next.set(folderData.id, {
id: folderData.id,
name: folderData.name ?? payload.name,
parentId: parentId,
children: folderData.children || [],
expanded: false,
loaded: false,
hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false,
});
return next;
});
// Refresh the parent folder to ensure consistency
const refreshTarget = targetParentId || 'root';
await ensureFolderData(refreshTarget, { force: true, prefetchDepth: 1 });
await selectFolder(folderData.id, { immediate: true });
succeeded = true;
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to create folder.';
notifyApiError(error, message);
return false;
} finally {
setCreatingFolder(false);
if (!succeeded) {
setStatusMessage('Folder creation failed.', 'error');
}
}
},
[
ensureFolderData,
notifyApiError,
selectFolder,
selectedFolder,
setCreatingFolder,
setFolderNodes,
setStatusMessage,
token,
],
);
const handleFolderDelete = useCallback(
async (folderId: FolderKey, { showMessage = true }: MessageOptions = {}) => {
if (!token) {
if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error');
}
return false;
}
if (!folderId || folderId === 'root') {
if (showMessage) {
setStatusMessage('The root folder cannot be removed.', 'error');
}
return false;
}
try {
const contents = await ensureFolderData(folderId, {
force: true,
prefetchDepth: 1,
});
const hasChildren = (contents.subfolders || []).length > 0;
const hasDocs = (contents.documents || []).length > 0;
if (hasChildren || hasDocs) {
if (showMessage) {
setStatusMessage('Folder must be empty before it can be deleted.', 'error');
}
return false;
}
await deleteFolder(folderId);
setFolderNodes((prev) => {
const next = new Map(prev);
const node = next.get(folderId);
next.delete(folderId);
if (node) {
const parentId = node.parentId || 'root';
const parentNode = next.get(parentId);
if (parentNode) {
const remaining = parentNode.children.filter((id) => id !== folderId);
next.set(parentId, {
...parentNode,
children: remaining,
hasChildren: remaining.length > 0,
});
}
}
return next;
});
setFolderContents((prev) => {
const next = new Map(prev);
next.delete(folderId);
return next;
});
if (selectedFolder === folderId) {
const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root';
setSelectedFolder(parentId);
const parentContents = await ensureFolderData(parentId, {
force: true,
prefetchDepth: 1,
});
applySelectedFolder(parentId, parentContents);
} else if (selectedFolder !== 'root') {
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
}
if (showMessage) {
setStatusMessage('Folder deleted.', 'success');
}
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to delete folder.';
notifyApiError(error, message);
if (showMessage) {
setStatusMessage(message, 'error');
}
return false;
}
},
[
token,
applySelectedFolder,
ensureFolderData,
folderNodes,
notifyApiError,
selectedFolder,
setFolderContents,
setFolderNodes,
setSelectedFolder,
setStatusMessage,
],
);
const folderClickHandlers: FolderClickHandlers = useMemo(
() => ({
onToggle: async (folderId: FolderKey) => {
const node = folderNodes.get(folderId);
const nextExpanded = !(node?.expanded ?? false);
if (nextExpanded) {
try {
await ensureFolderData(folderId, {
includeDocuments: false,
prefetchDepth: 1,
});
} catch (error) {
notifyApiError(error, 'Failed to load folder.');
}
} else if (node && !node.loaded) {
try {
await ensureFolderData(folderId, {
includeDocuments: false,
prefetchDepth: 1,
});
} catch (error) {
notifyApiError(error, 'Failed to load folder.');
}
}
setFolderNodes((prev) => {
const next = new Map(prev);
const current = next.get(folderId);
if (!current) return prev;
next.set(folderId, { ...current, expanded: nextExpanded });
return next;
});
},
onSelect: selectFolder,
onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.classList.remove('is-drop-target');
let folderIds: FolderId[] = [];
try {
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
if (rawFolderList) {
const parsed = JSON.parse(rawFolderList);
if (Array.isArray(parsed)) {
folderIds = parsed.filter(Boolean);
}
}
} catch (error) {
console.warn('[folders] Failed to parse folder list drag payload', error);
}
if (!folderIds.length) {
let folderSourceId = draggedFolderId;
if (!folderSourceId) {
try {
if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) {
folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder');
}
} catch (error) {
console.warn('[folders] Failed to read folder id from drag payload', error);
}
}
if (folderSourceId) {
folderIds = [folderSourceId];
}
}
folderIds = Array.from(new Set(folderIds.filter(Boolean)));
if (folderIds.length) {
setDraggedFolderId(null);
const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId));
if (invalidMove) {
setStatusMessage(
'Cannot move a folder into itself or one of its descendants.',
'error',
);
return;
}
for (const sourceId of folderIds) {
await moveFolder(sourceId, folderId);
}
}
if (hasFiles(event)) {
await handleFileDrop(event.dataTransfer, folderId);
return;
}
let docIds: FolderId[] = [];
try {
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
docIds = parsed.filter(Boolean);
}
}
} catch (error) {
console.warn('[documents] Failed to parse document list drag payload', error);
}
if (!docIds.length) {
try {
const single = event.dataTransfer.getData('application/x-papercrate-doc');
if (single) {
docIds = [single];
}
} catch (error) {
console.warn('[documents] Failed to read single document drag payload', error);
}
}
if (!docIds.length && draggedDocumentIds.length) {
docIds = draggedDocumentIds;
}
docIds = Array.from(new Set(docIds));
if (!docIds.length || folderId === selectedFolder) {
return;
}
setDraggedDocumentIds([]);
await moveDocumentsToFolder(docIds, folderId);
},
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
const folderDragActive = Boolean(draggedFolderId);
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
return;
}
if (hasFiles(event)) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
event.currentTarget.classList.add('is-drop-target');
return;
}
if (draggedDocumentIds.length || folderDragActive) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
event.currentTarget.classList.add('is-drop-target');
}
},
onDragLeave: (event: DragEvent<HTMLElement>) => {
event.currentTarget.classList.remove('is-drop-target');
},
}),
[
draggedDocumentIds,
draggedFolderId,
ensureFolderData,
folderNodes,
handleFileDrop,
isInvalidFolderDrop,
moveDocumentsToFolder,
moveFolder,
notifyApiError,
selectFolder,
selectedFolder,
setDraggedDocumentIds,
setDraggedFolderId,
setFolderNodes,
setStatusMessage,
],
);
return {
loadFolder,
selectFolder,
handleFolderRename,
handleFolderCreate,
handleFolderDelete,
folderClickHandlers,
};
};
export default useFolderTreeActions;