import { useCallback, useMemo } from 'react'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; const useFolderTreeActions = ({ api, token, folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, ensureFolderData, ensureFolderAncestorsLoaded, expandFolderAncestors, applySelectedFolder, notifyApiError, setStatusMessage, setLoading, setFolderContents, setCurrentFolder, setSearchResults, isFilterActive, navigate, handleFileDrop, moveDocumentsToFolder, draggedDocumentIds, draggedFolderId, setDraggedDocumentIds, setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, }) => { const moveFolder = useCallback( async (folderId, targetFolderId) => { 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 api.patch(`/folders/${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]); for (const key of refreshTargets) { await 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]); for (const key of refreshTargets) { await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); } } }, [ api, ensureFolderData, folderNodes, notifyApiError, selectedFolder, setFolderNodes, setSelectedFolder, setStatusMessage, ], ); const loadFolder = useCallback( async (folderId, { showLoading = true, preserveSearch = false } = {}) => { const targetId = folderId || 'root'; setSelectedFolder(targetId); await ensureFolderAncestorsLoaded(targetId); expandFolderAncestors(targetId); if (showLoading) setLoading(true); 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) { setSearchResults(null); } } catch (error) { notifyApiError(error, 'Failed to load folder contents.'); } finally { if (showLoading) setLoading(false); } }, [ applySelectedFolder, ensureFolderAncestorsLoaded, ensureFolderData, expandFolderAncestors, notifyApiError, setLoading, setSearchResults, setSelectedFolder, ], ); const selectFolder = useCallback( async (folderId, { replace = false, immediate = false } = {}) => { 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, nextName) => { if (!token) { setStatusMessage('Log in to rename folders.', 'error'); return false; } const trimmed = typeof nextName === 'string' ? nextName.trim() : ''; if (!trimmed) { setStatusMessage('Folder name cannot be empty.', 'error'); return false; } setLoading(true); try { await api.patch(`/folders/${folderId}`, { name: 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; } finally { setLoading(false); } }, [ api, notifyApiError, setCurrentFolder, setFolderContents, setFolderNodes, setLoading, setStatusMessage, token, ], ); const handleFolderCreate = useCallback( async (name) => { 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 api.post('/folders', payload); setStatusMessage('Folder created.', 'success'); setFolderNodes((prev) => { const next = new Map(prev); const parentId = payload.parent_id || 'root'; const parentNode = next.get(parentId); if (parentNode) { next.set(parentId, { ...parentNode, children: parentNode.children.concat([data.folder.id]), loaded: true, hasChildren: true, }); } next.set(data.folder.id, { id: data.folder.id, name: data.folder.name, parentId: parentId, children: [], expanded: false, loaded: false, hasChildren: false, }); return next; }); await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); await selectFolder(data.folder.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'); } } }, [ api, ensureFolderData, notifyApiError, selectFolder, selectedFolder, setCreatingFolder, setFolderNodes, setStatusMessage, token, ], ); const handleFolderDelete = useCallback( async (folderId, { showMessage = true, manageLoading = true } = {}) => { 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; } if (manageLoading) { setLoading(true); } 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 api.delete(`/folders/${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; } finally { if (manageLoading) { setLoading(false); } } }, [ api, token, applySelectedFolder, ensureFolderData, folderNodes, notifyApiError, selectedFolder, setFolderContents, setFolderNodes, setLoading, setSelectedFolder, setStatusMessage, ], ); const folderClickHandlers = useMemo( () => ({ onToggle: async (folderId) => { 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, folderId) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.classList.remove('is-drop-target'); let folderIds = []; 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 = []; 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, folderId) => { 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) => { 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;