import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react'; import { matchPath, useLocation, useMatch, useNavigate, } from 'react-router-dom'; import AssetManager, { getAssetFromVersion } from '../../asset_manager'; import useApiError from '../useApiError'; import TagManager from '../../tag_manager'; import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; import { api, useAppDispatch, useAppState } from '../../app/appState'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; import useDocumentsSelection from '../../documents/hooks/useDocumentsSelection'; import useBulkDocumentActions from '../../documents/hooks/useBulkDocumentActions'; import useDocumentsPanelProps from '../../documents/hooks/useDocumentsPanelProps'; import useDocumentPreview from '../../app/useDocumentPreview'; import useSidebarProps from '../../sidebar/useSidebarProps'; import { ASSET_PRESIGN_TTL_MS, DEFAULT_FOLDER_NAME, DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD, createRootNode, getRowId, isDocumentRowKey, isFolderRowKey, mergeAssetIntoDocument, resolveApiPath, resolveDocumentRowKey, resolveFolderRowKey, } from '../../app/appLayoutUtils'; import useDocumentsSearch from '../../app/useDocumentsSearch'; import useDocumentsStore from './store/useDocumentsStore'; import useAuthManager from './useAuthManager'; import useTags from './useTags'; import useCorrespondents from './useCorrespondents'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; import { fetchDocument } from '../../lib/apiClient'; import useFolderTree from './useFolderTree'; import useFolderTreeActions from './useFolderTreeActions'; import useDocumentTagging from './useDocumentTagging'; import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; import useDocumentUploads from './useDocumentUploads'; import useDocumentDragHandlers from './useDocumentDragHandlers'; import useDocumentMutations from './useDocumentMutations'; import useDetailWorkspace from '../../detail/useDetailWorkspace'; const EntryType = Object.freeze({ document: 'document', folder: 'folder', }); const noop = () => {}; type Identifier = string | number; type DocumentId = Identifier; type FolderId = Identifier | 'root'; interface DocumentLike { id?: DocumentId | null; title?: string | null; [key: string]: unknown; } interface FolderContentsEntry { folder?: { id?: FolderId; name?: string | null } | null; documents?: DocumentLike[]; subfolders?: Array<{ id?: FolderId; name?: string | null; [key: string]: unknown }>; __includesDocuments?: boolean; __sortField?: string | null; __sortDirection?: string | null; [key: string]: unknown; } interface TenantOption { id?: Identifier | null; name?: string | null; slug?: string | null; [key: string]: unknown; } interface UseDocumentsWorkspaceOptions { documentsViewMode?: string; documentsSortField?: string; documentsSortDirection?: string; documentsSortFieldRef?: MutableRefObject; documentsSortDirectionRef?: MutableRefObject; onDocumentsViewModeChange?: (mode: string) => void; onDocumentsSortFieldChange?: (field: string) => void; onDocumentsSortDirectionToggle?: () => void; searchIncludeDescendants?: boolean; onSetSearchIncludeDescendants?: (value: boolean) => void; sortRefreshReadyRef?: MutableRefObject; } const useDocumentsWorkspace = ({ documentsViewMode = 'list', documentsSortField = DEFAULT_SORT_FIELD, documentsSortDirection = DEFAULT_SORT_DIRECTION, documentsSortFieldRef, documentsSortDirectionRef, onDocumentsViewModeChange, onDocumentsSortFieldChange, onDocumentsSortDirectionToggle, searchIncludeDescendants = true, onSetSearchIncludeDescendants, sortRefreshReadyRef, }: UseDocumentsWorkspaceOptions = {}) => { const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop; const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop; const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop; const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop; const fallbackSortFieldRef = useRef(documentsSortField); const activeSortFieldRef = documentsSortFieldRef || fallbackSortFieldRef; useEffect(() => { activeSortFieldRef.current = documentsSortField; }, [documentsSortField, activeSortFieldRef]); const fallbackSortDirectionRef = useRef(documentsSortDirection); const activeSortDirectionRef = documentsSortDirectionRef || fallbackSortDirectionRef; useEffect(() => { activeSortDirectionRef.current = documentsSortDirection; }, [documentsSortDirection, activeSortDirectionRef]); const fallbackSortRefreshReadyRef = useRef(false); const activeSortRefreshReadyRef = sortRefreshReadyRef || fallbackSortRefreshReadyRef; const navigate = useNavigate(); const location = useLocation(); const appState = useAppState(); const appDispatch = useAppDispatch(); const folderMatch = matchPath('/documents/folder/:folderId', location.pathname); const docMatch = matchPath('/documents/:documentId', location.pathname); const routeFolderId = folderMatch?.params?.folderId || null; const routeDocumentId = docMatch?.params?.documentId || null; const previewDocumentId = routeDocumentId; const { status: appStatus, token, tenant, tenants: tenantOptionsRaw = [], } = appState; const tenantRecord = (tenant ?? null) as TenantOption | null; const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null; const tenantName = tenantNameCandidate ? String(tenantNameCandidate) : null; const currentTenantId: Identifier | null = (tenantRecord?.id ?? null) as Identifier | null; const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw) ? (tenantOptionsRaw as TenantOption[]) : []; const { status, setStatusMessage } = useDocumentsStore(); const handleApiReport = useCallback( ({ message, variant }) => setStatusMessage(message, variant), [setStatusMessage], ); const reportApiError = useApiError({ onReport: handleApiReport, }); const notifyApiError = useCallback( (error, fallbackMessage, variant = 'error') => reportApiError(error, { message: fallbackMessage, variant }), [reportApiError], ); const [loading, setLoading] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false); const { tokenRef, handleLogout } = useAuthManager({ apiClient: api, token, appStatus, appDispatch, notifyApiError, setStatusMessage, setLoading, }); const breadcrumbFetchRef = useRef(new Set()); const tagRemovalCursorActiveRef = useRef(false); const tenantIdRef = useRef(currentTenantId); const detailPanelControlRef = useRef({ open: () => {}, close: () => {} }); const setTagRemovalCursor = useCallback((active) => { if (tagRemovalCursorActiveRef.current === active) { return; } const body = document.body; if (!body) { return; } tagRemovalCursorActiveRef.current = active; if (active) { body.classList.add('desk-cursor-remove'); } else { body.classList.remove('desk-cursor-remove'); } }, []); const documentsRouteMatch = useMatch('/documents'); const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); const documentsDetailRouteMatch = useMatch('/documents/:documentId'); const isDocumentsRoute = Boolean( documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, ); const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); const [draggedFolderId, setDraggedFolderId] = useState(null); const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); const shellRef = useRef(null); const assetManagerRef = useRef(null); if (!assetManagerRef.current) { assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); } const assetManager = assetManagerRef.current; const extractDocumentFromResponse = useCallback( (payload) => { if (!payload) { return null; } return payload.document || payload; }, [], ); const fetchDocumentById = useCallback( async (documentId: DocumentId) => { if (!documentId) { return null; } const data = await fetchDocument(documentId); return extractDocumentFromResponse(data); }, [extractDocumentFromResponse], ); const tagManagerRef = useRef(null); if (!tagManagerRef.current) { tagManagerRef.current = new TagManager(); } const tagManager = tagManagerRef.current; const selection = useWorkspaceSelection({ resolveDocumentRowKey, resolveFolderRowKey, isDocumentRowKey, isFolderRowKey, getRowId, }); const { selectedEntries, selectedDocumentIds, selectedFolderIds, setSelectedEntries, selectionOrder, setSelectionOrder, selectionOrderRef, selectionAnchorRef, selectionInitializedRef, focusedDocumentId, setFocusedDocumentId, focusedRowKey, setFocusedRowKey, applySelection, handleEntrySelection, clearSelection, promoteSelectionOrder: promoteSelectionOrderRaw, configureSelectionEnvironment, } = selection; const selectionHelpers = useMemo( () => ({ setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, selectionInitializedRef, }), [ setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, selectionInitializedRef, ], ); const [folderContents, setFolderContents] = useState>( () => new Map(), ); const folderContentsRef = useRef(folderContents); useEffect(() => { folderContentsRef.current = folderContents; }, [folderContents]); const { documents, setDocuments, removeDocumentsFromLookup, mapDocumentCaches, updateDocumentCaches, documentsManager, } = useDocuments({ setFolderContents, fetchDocumentById, }); const documentLookup = useSyncExternalStore( (onStoreChange) => documentsManager.subscribe(onStoreChange), () => documentsManager.getSnapshot(), () => documentsManager.getSnapshot(), ); const { folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, currentFolder, setCurrentFolder, currentSubfolders, setCurrentSubfolders, currentFolderName, folderOptions, folderLabelMap, applySelectedFolder, ensureFolderData, ensureFolderAncestorsLoaded, expandFolderAncestors, isInvalidFolderDrop, } = useFolderTree({ initialSelectedFolder: routeFolderId || 'root', apiClient: api, tenantIdRef, documentsSortFieldRef: activeSortFieldRef, documentsSortDirectionRef: activeSortDirectionRef, selectionHelpers, setDocuments, setFolderContents, folderContentsRef, }); const { searchQuery, setSearchQuery, searchResultIds, setSearchResultIds, searchLoading, activeTagFilters, setActiveTagFilters, activeCorrespondentFilters, setActiveCorrespondentFilters, isFilterActive, documentsFilterValue, } = useDocumentsSearch({ api, token, selectedFolder, navigate, locationPathname: location.pathname, isDocumentsRoute, searchIncludeDescendants, documentsSortField, documentsSortDirection, notifyApiError, setLoading, setSearchIncludeDescendants, documentsManager, }); const documentsFilter = documentsFilterValue; const [visibleDocumentIds, setVisibleDocumentIds] = useState([]); const showingSearchResults = searchResultIds !== null; useEffect(() => { const arraysEqual = (a: DocumentId[], b: DocumentId[]) => a.length === b.length && a.every((value, index) => value === b[index]); if (showingSearchResults && Array.isArray(searchResultIds)) { const ids = searchResultIds.filter((id): id is DocumentId => id != null); setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids)); return; } const folderIds = documents .map((doc) => (doc?.id ?? null) as DocumentId | null) .filter((id): id is DocumentId => id != null); setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds)); }, [showingSearchResults, searchResultIds, documents]); const viewDocuments = useMemo( () => visibleDocumentIds .map((id) => documentLookup.get(id) || null) .filter((doc): doc is DocumentLike => Boolean(doc)), [visibleDocumentIds, documentLookup], ); const visibleDocumentKeys = useMemo( () => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean), [visibleDocumentIds], ); const visibleFolderKeys = useMemo( () => showingSearchResults ? [] : currentSubfolders .map((folder) => resolveFolderRowKey(folder.id)) .filter(Boolean), [showingSearchResults, currentSubfolders], ); const visibleRowKeys = useMemo( () => [...visibleFolderKeys, ...visibleDocumentKeys], [visibleFolderKeys, visibleDocumentKeys], ); const visibleRowKeySet = useMemo( () => new Set(visibleRowKeys), [visibleRowKeys], ); const { documentLinks, ensureDownloadUrl, openDocumentPreview, closeDocumentPreview, resetPreviewState, removeDocumentLinks, } = useDocumentPreview({ routeDocumentId: previewDocumentId, documentsManager, selectedFolder, notifyApiError, navigate, locationPathname: location.pathname, locationSearch: location.search, detailPanelControlRef, setActivePreviewId, }); const openDocumentPreviewForDetail = useCallback( ({ documentIds }: { documentIds?: Identifier[] } = {}) => { const targetId = documentIds?.find((value): value is Identifier => value != null); if (targetId == null) { return; } openDocumentPreview(targetId, { replace: true }); }, [openDocumentPreview], ); const getDocumentAsset = useCallback((doc, type) => { if (!doc || !type) return null; return getAssetFromVersion(doc.current_version || null, type); }, []); const bootstrapInitializedRef = useRef(false); const detailFolderFetchRef = useRef(new Set()); useEffect(() => { if (!showingSearchResults) { return; } setSelectedEntries([]); setSelectionOrder([]); selectionOrderRef.current = []; selectionAnchorRef.current = null; setFocusedDocumentId(null); }, [ showingSearchResults, searchQuery, setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, ]); const { tags, refreshTags, handleTagCreate, handleTagUpdate, handleTagDelete, setTags, } = useTags({ apiClient: api, notifyApiError, setStatusMessage, tagManager, tenantIdRef, setActiveTagFilters, mapDocumentCaches, }); useEffect(() => { tenantIdRef.current = currentTenantId; }, [currentTenantId]); useEffect(() => { if (!selectedDocumentIds.length) { return; } if (!selectedDocumentIds.includes(activePreviewId)) { setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); } selectionInitializedRef.current = true; }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]); const tagLookupById = useMemo(() => { const map = new Map(); tags.forEach((tag) => { if (tag?.id) { map.set(tag.id, tag); } }); return map; }, [tags]); const { correspondents, refreshCorrespondents, handleCorrespondentCreate, handleCorrespondentUpdate, handleCorrespondentDelete, setCorrespondents, } = useCorrespondents({ apiClient: api, notifyApiError, setStatusMessage, tenantIdRef, mapDocumentCaches, }); const { passkeys, passkeysSupported, passkeysLoading, registeringPasskey, revokingPasskeyId, refreshPasskeys, registerPasskey, revokePasskey, } = usePasskeys({ api, notifyApiError, setStatusMessage, token, }); const resolveTargetDocumentIds = useCallback( (candidateIds) => { const normalized = Array.isArray(candidateIds) ? candidateIds.filter(Boolean) : []; if (normalized.length) { return Array.from(new Set(normalized)); } return selectedDocumentIds; }, [selectedDocumentIds], ); const refreshCurrentFolder = useCallback(async () => { setLoading(true); try { const contents = await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1, }); applySelectedFolder(selectedFolder, contents); } catch (error) { notifyApiError(error, 'Failed to refresh folder.'); } finally { setLoading(false); } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); const { handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, handleBulkSelectionReanalyze, } = useDocumentTagging({ apiClient: api, tags, tagManager, refreshTags, resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, updateDocumentCaches, }); const { dropOverlayState, handleFileDrop, handleFileSelection, uploadQueue, clearUploadQueue, resetUploadsState, } = useDocumentUploads({ apiClient: api, token, selectedFolder, currentFolderName, ensureFolderData, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading, shellRef, }); const { handleDocumentDragStart, handleDocumentDragEnd, handleFolderDragStart, handleFolderDragEnd, } = useDocumentDragHandlers({ selectedEntries, selectedDocumentIds, selectedFolderIds, applySelection, handleEntrySelection, documentLookup, setDraggedDocumentIds, setDraggedFolderId, resolveDocumentRowKey, resolveFolderRowKey, documentsViewMode, }); const { correspondentLookupByName, handleDocumentCorrespondentAttach, handleCorrespondentRemove, handleCorrespondentAdd, } = useDocumentCorrespondentActions({ apiClient: api, correspondents, handleCorrespondentCreate, notifyApiError, setStatusMessage, updateDocumentCaches, }); useEffect(() => { if (!activeSortRefreshReadyRef.current) { activeSortRefreshReadyRef.current = true; return; } if (!isFilterActive && token) { refreshCurrentFolder(); } }, [ documentsSortField, documentsSortDirection, isFilterActive, refreshCurrentFolder, token, activeSortRefreshReadyRef, ]); const resetWorkspaceState = useCallback(() => { const rootNode = createRootNode(); setFolderNodes(new Map([[rootNode.id, rootNode]])); setFolderContents(new Map()); setSelectedFolder('root'); setCurrentFolder(null); setCurrentSubfolders([]); setDocuments([]); setSelectedEntries([]); setSelectionOrder([]); selectionOrderRef.current = []; setFocusedDocumentId(null); selectionAnchorRef.current = null; setDraggedDocumentIds([]); setDraggedFolderId(null); setSearchResultIds(null); setTags([]); setCorrespondents([]); setSearchQuery(''); setActiveTagFilters([]); setActiveCorrespondentFilters([]); setActivePreviewId(null); detailPanelControlRef.current.close(); assetManager.reset(); resetPreviewState(); resetUploadsState(); clearUploadQueue(); breadcrumbFetchRef.current = new Set(); detailFolderFetchRef.current = new Set(); bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; tenantIdRef.current = null; }, [ assetManager, selectionAnchorRef, selectionInitializedRef, selectionOrderRef, setFocusedDocumentId, setSelectedEntries, setSelectionOrder, setFolderNodes, setFolderContents, setSelectedFolder, setCurrentFolder, setCurrentSubfolders, setDocuments, setDraggedDocumentIds, setDraggedFolderId, setSearchResultIds, setTags, setCorrespondents, setSearchQuery, setActiveTagFilters, setActiveCorrespondentFilters, setActivePreviewId, resetPreviewState, resetUploadsState, clearUploadQueue, ]); useEffect(() => { if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { resetWorkspaceState(); } }, [appStatus, resetWorkspaceState]); const removeDocumentsFromCaches = useCallback( (documentIds: DocumentId[]) => { if (!documentIds.length) { return; } const idSet = new Set(documentIds); setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id))); setSearchResultIds((prev) => { if (!Array.isArray(prev)) { return prev; } const filtered = prev.filter((id) => !idSet.has(id as DocumentId)); return filtered.length === prev.length ? prev : filtered; }); setFolderContents((prev: Map) => { if (!prev.size) { return prev; } let changed = false; const next = new Map(); prev.forEach((contents, key) => { const docs = Array.isArray(contents?.documents) ? contents.documents : null; if (!docs || docs.length === 0) { next.set(key, contents); return; } const filtered = docs.filter((doc) => !idSet.has(doc.id)); if (filtered.length !== docs.length) { changed = true; next.set(key, { ...contents, documents: filtered }); } else { next.set(key, contents); } }); return changed ? next : prev; }); removeDocumentsFromLookup(Array.from(idSet)); removeDocumentLinks(Array.from(idSet)); }, [ setDocuments, setSearchResultIds, setFolderContents, removeDocumentsFromLookup, removeDocumentLinks, ], ); const { moveDocumentsToFolder, handleThumbnailRegeneration, handleDocumentsDelete, handleDocumentTagAdd, handleDocumentTagAttach, handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleTagRemove, } = useDocumentMutations({ api, token, documentLookup, folderLabelMap, ensureFolderData, selectedFolder, setSelectedFolder, setDocuments, setFolderContents, setSearchResultIds, setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, setFocusedRowKey, focusedRowKey, notifyApiError, setStatusMessage, setLoading, mapDocumentCaches, applySelectedFolder, folderNodes, setFolderNodes, removeDocumentsFromCaches, closeDocumentPreview, previewDocumentId, refreshCurrentFolder, updateDocumentCaches, tagLookupById, tags, refreshTags, tagManager, extractDocumentFromResponse, ingestDocuments: (docs) => documentsManager.ingest(docs), }); const { loadFolder, selectFolder, handleFolderRename, handleFolderCreate, handleFolderDelete, folderClickHandlers, } = useFolderTreeActions({ api, token, folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, ensureFolderData, ensureFolderAncestorsLoaded, expandFolderAncestors, applySelectedFolder, notifyApiError, setStatusMessage, setLoading, setFolderContents, setCurrentFolder, setSearchResultIds, isFilterActive, navigate, handleFileDrop, moveDocumentsToFolder, draggedDocumentIds, draggedFolderId, setDraggedDocumentIds, setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, }); const { promoteSelectionOrder, clearDocumentSelection, } = useDocumentsSelection({ showingSearchResults, currentSubfolders, visibleDocuments: viewDocuments, resolveFolderRowKey, resolveDocumentRowKey, configureSelectionEnvironment, visibleRowKeySet, selectedEntries, selectionAnchorRef, promoteSelectionOrderRaw, setFocusedDocumentId, setActivePreviewId, clearSelection, focusedDocumentId, setFocusedRowKey, focusedRowKey, isFolderRowKey, }); const initializeAfterLogin = useCallback(async () => { setLoading(true); try { await Promise.all([refreshTags(), refreshCorrespondents()]); const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; await loadFolder(initialFolder, { showLoading: false }); } catch (error) { notifyApiError(error, 'Failed to initialize data.'); throw error; } finally { setLoading(false); } }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]); useEffect(() => { if (!token) { return; } if (appStatus !== 'ready' && appStatus !== 'bootstrapping') { return; } const targetParam = routeFolderId ?? 'root'; if (targetParam === 'root' && routeDocumentId) { return; } const hasData = folderContents.has(targetParam); if (targetParam !== selectedFolder || !hasData) { selectFolder(targetParam, { immediate: true }); } }, [ token, appStatus, routeFolderId, routeDocumentId, selectedFolder, folderContents, isFilterActive, selectFolder, ]); useEffect(() => { if (appStatus !== 'authenticated') { return; } if (bootstrapInitializedRef.current) { return; } let cancelled = false; const bootstrap = async () => { bootstrapInitializedRef.current = true; appDispatch({ type: 'BOOTSTRAP_START' }); try { await initializeAfterLogin(); if (!cancelled) { appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); } } catch (error) { if (!cancelled) { appDispatch({ type: 'BOOTSTRAP_FAILURE', error: error?.message || 'Failed to initialize data.', }); bootstrapInitializedRef.current = false; } } }; bootstrap(); return () => { cancelled = true; }; }, [appStatus, appDispatch, initializeAfterLogin]); const { handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleDeleteSelection, } = useBulkDocumentActions({ api, resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, setStatusMessage, selectedDocumentIds, selectedFolderIds, handleDocumentsDelete, handleFolderDelete, clearDocumentSelection, setLoading, updateDocumentCaches, }); const ensureAssetUrl = useCallback( async (documentId, asset, { force = false } = {}) => { if (!documentId || !asset?.id) { return null; } try { const entry = await assetManager.ensureAsset(documentId, asset, { force, }); if (!entry) { return null; } updateDocumentCaches(documentId, (doc) => mergeAssetIntoDocument(doc, entry)); return entry; } catch (error) { notifyApiError(error, 'Unable to refresh document asset.'); throw error; } }, [assetManager, updateDocumentCaches, notifyApiError], ); const handleDocumentTagDrop = useCallback( async (documentId, tag) => { if (!documentId || !tag?.id) { return; } if (tag.sourceDocId && tag.sourceDocId === documentId) { return; } const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag }); if (!attached) { return; } if (tag.sourceDocId && tag.sourceDocId !== documentId) { await handleTagRemove(tag.sourceDocId, tag.id, { refreshTagList: false, showMessage: false, }); } }, [handleDocumentTagAttach, handleTagRemove], ); const handlePromptCreateFolder = useCallback(async () => { if (creatingFolder) { return; } const input = window.prompt('New folder name'); if (!input) { return; } const trimmed = input.trim(); if (!trimmed) { setStatusMessage('Folder name cannot be empty.', 'error'); return; } setCreatingFolder(true); try { const success = await handleFolderCreate(trimmed); if (!success) { setStatusMessage('Unable to create folder. Check the status message for details.', 'error'); } } finally { setCreatingFolder(false); } }, [creatingFolder, handleFolderCreate, setStatusMessage]); const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({ locationPathname: location.pathname, tags, refreshTags, onTagCreate: handleTagCreate, onTagUpdate: handleTagUpdate, onTagDelete: handleTagDelete, correspondents, refreshCorrespondents, onCorrespondentCreate: handleCorrespondentCreate, onCorrespondentUpdate: handleCorrespondentUpdate, onCorrespondentDelete: handleCorrespondentDelete, setStatusMessage, }); const [settingsOpen, setSettingsOpen] = useState(false); const openSettings = useCallback(() => { setSettingsOpen(true); }, []); const closeSettings = useCallback(() => { setSettingsOpen(false); }, []); useEffect(() => { if (!settingsOpen) { return undefined; } const handleKeyDown = (event) => { if (event.key === 'Escape') { event.preventDefault(); setSettingsOpen(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [settingsOpen]); useEffect( () => () => { setTagRemovalCursor(false); }, [setTagRemovalCursor], ); useEffect(() => { const host = shellRef.current; if (!host) { return undefined; } const isTagTransfer = (event) => isTagTransferEvent(event); const isDocumentDropTarget = (target) => target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false; const handleTagDragOver = (event) => { if (!isTagTransfer(event)) { return; } if (isDocumentDropTarget(event.target)) { setTagRemovalCursor(false); return; } event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setTagRemovalCursor(true); }; const handleTagDragLeave = (event) => { if (!isTagTransfer(event)) { return; } const related = event.relatedTarget; if (related instanceof Element && host.contains(related)) { if (isDocumentDropTarget(related)) { setTagRemovalCursor(false); } return; } setTagRemovalCursor(false); }; const handleTagDrop = async (event) => { if (!isTagTransfer(event)) { return; } setTagRemovalCursor(false); if (isDocumentDropTarget(event.target) || event.defaultPrevented) { return; } event.preventDefault(); event.stopPropagation(); const raw = event.dataTransfer.getData('application/x-papercrate-tag') || event.dataTransfer.getData('text/papercrate-tag'); if (!raw) { return; } try { const payload = JSON.parse(raw); if (payload?.sourceDocId && payload?.id) { await handleTagRemove(payload.sourceDocId, payload.id, { refreshTagList: false, showMessage: true, }); } } catch (error) { console.warn('Failed to remove tag from drop target', error); } }; const handleTagDragEnd = () => { setTagRemovalCursor(false); }; host.addEventListener('dragover', handleTagDragOver, true); host.addEventListener('dragleave', handleTagDragLeave, true); host.addEventListener('drop', handleTagDrop, true); window.addEventListener('dragend', handleTagDragEnd, true); return () => { host.removeEventListener('dragover', handleTagDragOver, true); host.removeEventListener('dragleave', handleTagDragLeave, true); host.removeEventListener('drop', handleTagDrop, true); window.removeEventListener('dragend', handleTagDragEnd, true); setTagRemovalCursor(false); }; }, [handleTagRemove, setTagRemovalCursor]); const { detailPanelProps, detailPanelOpen, openDetailPanel, inspectDocument, previewActive, previewWorkspaceDocument, resolveFolderPath, } = useDetailWorkspace({ documents: viewDocuments, selectionOrder, selectedDocumentIds, documentLookup, folderNodes, ensureFolderData, detailPanelControlRef, detailFolderFetchRef, previewDocumentId, activePreviewId, openDocumentPreview: openDocumentPreviewForDetail, handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleDocumentTagAdd, handleTagRemove, ensureAssetUrl, getDocumentAsset, correspondents, handleCorrespondentAdd, handleCorrespondentRemove, resolveApiPath, selectFolder, tags, tagLookupById, }); const inspectDocumentForDesk = useCallback( (docOrId?: DocumentLike | Identifier | null) => { if (docOrId == null) { return; } const docId: Identifier | null = Object(docOrId) === docOrId ? (docOrId as DocumentLike)?.id ?? null : (docOrId as Identifier | null); if (docId == null) { return; } inspectDocument(docId); }, [inspectDocument], ); const handleEntryPointerCore = useEntryPointerCore({ resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => { const { type, id } = entry; const key = rowKey || (type === EntryType.document ? resolveDocumentRowKey(id) : resolveFolderRowKey(id)); if (key) { handleEntrySelection(key, event); } if (type === EntryType.folder && !modifierClick && primaryClick) { selectFolder(id); } }, }); const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const chain = []; const seen = new Set(); const pending = new Set(); let currentId = selectedFolder || 'root'; let guard = 0; while (currentId && !seen.has(currentId) && guard < 32) { guard += 1; seen.add(currentId); if (currentId === 'root') { chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); currentId = null; break; } const node = folderNodes.get(currentId); if (node) { chain.push({ id: currentId, name: node.name || 'Folder' }); currentId = node.parentId ?? 'root'; continue; } let fallbackName = '…'; let parentId = null; if (currentFolder && currentFolder.id === currentId) { fallbackName = currentFolder.name; parentId = currentFolder.parent_id ?? 'root'; } chain.push({ id: currentId, name: fallbackName }); pending.add(currentId); currentId = parentId; } if (!chain.some((crumb) => crumb.id === 'root')) { chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); } const ordered = []; const seenOrdered = new Set(); chain .slice() .reverse() .forEach((crumb) => { if (!seenOrdered.has(crumb.id)) { seenOrdered.add(crumb.id); ordered.push(crumb); } }); return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) }; }, [selectedFolder, folderNodes, currentFolder]); useEffect(() => { if (!missingBreadcrumbAncestors.length) { return; } missingBreadcrumbAncestors.forEach((folderId) => { if (!folderId || folderId === 'root') { return; } if (breadcrumbFetchRef.current.has(folderId)) { return; } breadcrumbFetchRef.current.add(folderId); ensureFolderData(folderId, { force: false }) .catch((error) => { console.warn('Failed to preload breadcrumb ancestor', folderId, error); }) .finally(() => { breadcrumbFetchRef.current.delete(folderId); }); }); }, [missingBreadcrumbAncestors, ensureFolderData]); const { handleTenantSelect } = useTenantManager({ apiClient: api, appDispatch, currentTenantId, resetWorkspaceState, setStatusMessage, notifyApiError, setLoading, refreshTags, refreshCorrespondents, loadFolder, handleDocumentsViewModeChange, navigate, tokenRef, tenantIdRef, }); const handleDeskDocumentStackSelect = useCallback( (docIds: Array) => { if (!Array.isArray(docIds) || docIds.length === 0) { return; } const rowKeys = docIds .map((id) => resolveDocumentRowKey(id as Identifier)) .filter((value): value is string => typeof value === 'string'); if (!rowKeys.length) { return; } const nextKeys = [...selectedEntries]; rowKeys.forEach((key) => { if (!nextKeys.includes(key)) { nextKeys.push(key); } }); const anchor = (rowKeys[0] || selectionAnchorRef.current || nextKeys[nextKeys.length - 1]) as Identifier | string | null; applySelection(nextKeys, { anchor, interactedKeys: rowKeys, }); }, [applySelection, selectedEntries, selectionAnchorRef], ); const deskViewId = useMemo(() => { if (showingSearchResults) { const trimmedQuery = searchQuery.trim(); const tagsKey = [...activeTagFilters].sort().join(','); const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; } const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; return `folder:${folderKey}`; }, [ showingSearchResults, searchQuery, activeTagFilters, activeCorrespondentFilters, selectedFolder, ]); const deskWorkspaceProps = useMemo( () => ({ documents: viewDocuments, onInspectDocument: inspectDocumentForDesk, onEntryPointer: handleEntryPointerCore, onDocumentStackSelect: handleDeskDocumentStackSelect, onPromoteSelection: promoteSelectionOrder, onAssignTagToDocument: handleDocumentTagDrop, ensureAssetUrl, getDocumentAsset, activeTagIds: activeTagFilters, selectedDocumentIds, onClearSelection: clearDocumentSelection, tenantId: currentTenantId, viewId: deskViewId, documentLinks, ensureDownloadUrl, }), [ viewDocuments, inspectDocumentForDesk, handleEntryPointerCore, handleDeskDocumentStackSelect, promoteSelectionOrder, handleDocumentTagDrop, ensureAssetUrl, getDocumentAsset, activeTagFilters, selectedDocumentIds, clearDocumentSelection, currentTenantId, deskViewId, documentLinks, ensureDownloadUrl, ], ); const documentsPanelProps = useDocumentsPanelProps({ currentFolderName, breadcrumbs, refreshCurrentFolder, currentSubfolders, documents: viewDocuments, searchResultIds, folderClickHandlers, handleFolderDragStart, handleFolderDragEnd, draggedFolderId, handleFolderRename, openDocumentPreview, handleDocumentTitleUpdate, draggedDocumentIds, handleDocumentDragStart, handleDocumentDragEnd, searchLoading, tagLookupById, activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, handleDocumentTagDrop, documentsViewMode, documentsSortField, documentsSortDirection, handleDocumentsSortFieldChange, handleDocumentsSortDirectionToggle, handleDocumentsViewModeChange, clearDocumentSelection, handleDeleteSelection, handleEntryPointerCore, inspectDocument, tags, correspondents, documentLookup, handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleBulkSelectionReanalyze, folderOptions, moveDocumentsToFolder, selectFolder, documentLinks, ensureDownloadUrl, selectionValue: selection, }); const documentsTableProps = useMemo( () => ({ ...documentsPanelProps, deskWorkspaceProps, }), [documentsPanelProps, deskWorkspaceProps], ); const sidebarProps = useSidebarProps({ folderNodes, folderClickHandlers, handleFolderDelete, handleFolderRename, selectedFolder, handleFolderDragStart, handleFolderDragEnd, draggedFolderId, handlePromptCreateFolder, creatingFolder, tags, handleTagCreate, correspondents, handleCorrespondentCreate, appStatus, loading, previewActive, handleLogout, status, tenantName, tenantOptions, currentTenantId, handleTenantSelect, openSettings, handleFileSelection, uploadQueue, }); const contextValue = useMemo( () => ({ token, appStatus, status, setStatusMessage, dropOverlayState, handleLogout, sidebarProps, tags, refreshTags, handleTagUpdate, handleTagDelete, handleDocumentTagDrop, correspondents, refreshCorrespondents, handleCorrespondentUpdate, handleCorrespondentCreate, handleCorrespondentDelete, handleDocumentCorrespondentAttach, handleCorrespondentRemove, handleCorrespondentAdd, passkeys, passkeysSupported, passkeysLoading, registeringPasskey, revokingPasskeyId, refreshPasskeys, registerPasskey, revokePasskey, previewActive, previewWorkspaceDocument, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, documentsTableProps, detailPanelProps, documentsViewMode, ensureAssetUrl, resolveFolderPath, getDocumentAsset, notifyApiError, openTagsModal, openCorrespondentsModal, openSettings, detailPanelOpen, openDetailPanel, uploadQueue, clearUploadQueue, documentsFilter, }), [ token, appStatus, status, setStatusMessage, dropOverlayState, handleLogout, sidebarProps, tags, refreshTags, handleTagUpdate, handleTagDelete, handleDocumentTagDrop, correspondents, refreshCorrespondents, handleCorrespondentUpdate, handleCorrespondentCreate, handleCorrespondentDelete, handleDocumentCorrespondentAttach, handleCorrespondentRemove, handleCorrespondentAdd, passkeys, passkeysSupported, passkeysLoading, registeringPasskey, revokingPasskeyId, refreshPasskeys, registerPasskey, revokePasskey, previewActive, previewWorkspaceDocument, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, documentsTableProps, detailPanelProps, documentsViewMode, ensureAssetUrl, resolveFolderPath, getDocumentAsset, notifyApiError, openTagsModal, openCorrespondentsModal, openSettings, detailPanelOpen, openDetailPanel, uploadQueue, clearUploadQueue, documentsFilter, ], ); // hook callers handle rendering / routing return { appStatus, location, shellRef, dropOverlayState, managementModals, contextValue, settingsOpen, closeSettings, }; }; export default useDocumentsWorkspace;