import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import type { DragEvent } from 'react'; import { useStatusToast } from '../../../lib/context/StatusToastContext'; 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, FolderNode } from '../../../types/documents'; type FolderKey = FolderId | 'root'; interface LoadFolderOptions { preserveSearch?: boolean; } interface SelectFolderOptions { replace?: boolean; immediate?: boolean; } interface FolderClickHandlers { onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise; onDrop: (event: DragEvent, folderId: FolderKey) => Promise; onDragOver: (event: DragEvent, folderId: FolderKey) => void; onDragLeave: (event: DragEvent) => void; } import useNotifyApiError from '../../../hooks/useNotifyApiError'; interface UseFolderTreeActionsOptions { folderNodes: Map; setFolderNodes: (updater: (prev: Map) => Map) => void; selectedFolder: FolderKey; setSelectedFolder: (folderId: FolderKey) => 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 = ({ folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, handleFileDrop, moveDocumentsToFolder, draggedDocumentIds, draggedFolderId, setDraggedDocumentIds, setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, }: UseFolderTreeActionsOptions) => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); const navigate = useNavigate(); const moveFolder = useCallback( async (folderId: FolderKey, targetFolderId: FolderKey | null) => { const node = folderNodes.get(folderId); if (!node) { showToast('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; }); if (selectedFolder === folderId) { setSelectedFolder(folderId); } showToast('Folder moved.', 'success'); } catch (error) { const message = error.response?.data?.error || 'Failed to move folder.'; notifyApiError(error, message); } }, [ folderNodes, notifyApiError, selectedFolder, setFolderNodes, setSelectedFolder, showToast, ], ); const loadFolder = useCallback( async (folderId: FolderKey | null, { preserveSearch: _preserveSearch = false }: LoadFolderOptions = {}) => { const targetId = folderId || 'root'; setSelectedFolder(targetId); // Data fetching is now reactive in the parent component based on selectedFolder }, [setSelectedFolder], ); const selectFolder = useCallback( async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; if (!navigate || immediate) { await loadFolder(targetId); return; } const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; navigate(path, { replace }); }, [ loadFolder, navigate, ], ); const handleFolderRename = useCallback( async (folderId: FolderKey, nextName: string) => { const trimmed = nextName?.trim?.() || ''; if (!trimmed) { showToast('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; }); showToast('Folder renamed.', 'success'); return true; } catch (error) { const message = error.response?.data?.error || 'Failed to rename folder.'; notifyApiError(error, message); return false; } }, [ notifyApiError, setFolderNodes, showToast, ], ); const handleFolderCreate = useCallback( async (name: string, parentId?: FolderKey | null) => { if (!name.trim()) { showToast('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.'); } showToast('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 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) { showToast('Folder creation failed.', 'error'); } } }, [ notifyApiError, selectFolder, selectedFolder, setCreatingFolder, setFolderNodes, showToast, ], ); const handleFolderDelete = useCallback( async (folderId: FolderKey, { showMessage = true }: MessageOptions = {}) => { if (!folderId || folderId === 'root') { if (showMessage) { showToast('The root folder cannot be removed.', 'error'); } return false; } try { 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; }); if (selectedFolder === folderId) { const node = folderNodes.get(folderId); const parentId = node?.parentId || 'root'; setSelectedFolder(parentId); } if (showMessage) { showToast('Folder deleted.', 'success'); } return true; } catch (error) { const message = error.response?.data?.error || 'Failed to delete folder.'; notifyApiError(error, message); if (showMessage) { showToast(message, 'error'); } return false; } }, [ folderNodes, notifyApiError, selectedFolder, setFolderNodes, setSelectedFolder, showToast, ], ); const folderClickHandlers: FolderClickHandlers = useMemo( () => ({ 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) { showToast( '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, handleFileDrop, isInvalidFolderDrop, moveDocumentsToFolder, moveFolder, selectFolder, selectedFolder, setDraggedDocumentIds, setDraggedFolderId, showToast, ], ); return { loadFolder, selectFolder, handleFolderRename, handleFolderCreate, handleFolderDelete, folderClickHandlers, }; }; export default useFolderTreeActions;