1685 lines
44 KiB
TypeScript
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;
|