Files
papercrate/frontend/src/hooks/documents/useDocumentsWorkspace.ts
T
2025-11-22 04:22:18 +01:00

1685 lines
44 KiB
TypeScript

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<string>;
documentsSortDirectionRef?: MutableRefObject<string>;
onDocumentsViewModeChange?: (mode: string) => void;
onDocumentsSortFieldChange?: (field: string) => void;
onDocumentsSortDirectionToggle?: () => void;
searchIncludeDescendants?: boolean;
onSetSearchIncludeDescendants?: (value: boolean) => void;
sortRefreshReadyRef?: MutableRefObject<boolean>;
}
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<DocumentId[]>([]);
const [draggedFolderId, setDraggedFolderId] = useState<FolderId | null>(null);
const [activePreviewId, setActivePreviewId] = useState<DocumentId | null>(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<Map<FolderId, FolderContentsEntry>>(
() => 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<DocumentId[]>([]);
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<DocumentId>(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<FolderId, FolderContentsEntry>) => {
if (!prev.size) {
return prev;
}
let changed = false;
const next = new Map<FolderId, FolderContentsEntry>();
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<Identifier | string>) => {
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;