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/apiClient'; type FolderId = string | number; 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; onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise; onDrop: (event: DragEvent, folderId: FolderKey) => Promise; onDragOver: (event: DragEvent, folderId: FolderKey) => void; onDragLeave: (event: DragEvent) => void; } interface UseFolderTreeActionsOptions { token?: string | null; folderNodes: Map; setFolderNodes: (updater: (prev: Map) => Map) => void; selectedFolder: FolderKey; setSelectedFolder: (folderId: FolderKey) => void; ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise; ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise; expandFolderAncestors: (folderId: FolderKey) => void; applySelectedFolder: (folderId: FolderKey, contents: any) => void; notifyApiError: (error: unknown, message?: string) => void; setStatusMessage: (message: string, level?: string) => void; setFolderContents: ( updater: (prev: Map) => Map, ) => 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; moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise; 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) => { if (!token) { setStatusMessage('Log in to create folders.', 'error'); return false; } if (!name.trim()) { setStatusMessage('Folder name cannot be empty.', 'error'); return false; } const payload = { name: name.trim(), parent_id: selectedFolder === 'root' ? null : selectedFolder, }; 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; }); await ensureFolderData(selectedFolder, { 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 }: { showMessage?: boolean } = {}) => { 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, 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, 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) => { 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;