1156 lines
31 KiB
TypeScript
1156 lines
31 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from 'react';
|
|
import {
|
|
matchPath,
|
|
useLocation,
|
|
useMatch,
|
|
useNavigate,
|
|
} from 'react-router-dom';
|
|
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
|
|
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
|
import TagManager from '../../lib/assets/TagManager';
|
|
import { fetchAsset } from '../../lib/api/apiClient';
|
|
import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer';
|
|
import useDocumentsSelection from '../features/selection/useDocumentsSelection';
|
|
import useBulkDocumentActions from './useBulkDocumentActions';
|
|
import {
|
|
DEFAULT_SORT_DIRECTION,
|
|
DEFAULT_SORT_FIELD,
|
|
mergeAssetIntoDocument,
|
|
} from '../../app/workspaceUtils';
|
|
import {
|
|
createDocumentEntryKey,
|
|
createFolderEntryKey,
|
|
isFolderEntry,
|
|
isDocumentEntry
|
|
} from '../../app/entryKey';
|
|
import useDocumentsSearch from '../../app/useDocumentsSearch';
|
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
|
import useAuthManager from './useAuthManager';
|
|
import useTenantManager from './useTenantManager';
|
|
import useDocuments from './useDocuments';
|
|
import FoldersManager from '../FoldersManager';
|
|
import { fetchDocument } from '../../lib/api/apiClient';
|
|
import useFolderTree from '../features/folders/useFolderTree';
|
|
import useFolderTreeActions from '../features/folders/useFolderTreeActions';
|
|
import useDocumentTagActions from '../features/tagging/useDocumentTagActions';
|
|
import useDocumentUploads from '../features/upload/useDocumentUploads';
|
|
import useDocumentDragHandlers from '../features/upload/useDocumentDragHandlers';
|
|
import useDocumentMutations from './useDocumentMutations';
|
|
import useDetailWorkspace from '../../viewer/logic/useDetailWorkspace';
|
|
import useTags from './useTags';
|
|
import useCorrespondents from './useCorrespondents';
|
|
import useDocumentCorrespondentActions from '../features/correspondents/useDocumentCorrespondentActions';
|
|
import usePasskeys from '../../settings/usePasskeys';
|
|
import { resolveBreadcrumbs } from '../logic/breadcrumbs';
|
|
import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync';
|
|
import useWorkspaceViewData from './useWorkspaceViewData';
|
|
import { useManagementModals } from '../../app/useManagementModals';
|
|
import { useAppDispatch, useAppState } from '../../lib/store/appState';
|
|
import { listFolderContents } from '../../lib/api/apiClient';
|
|
import { useApi } from '../../lib/context/ApiContext';
|
|
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
|
|
import useDocumentPreview from '../../app/useDocumentPreview';
|
|
import { createRootNode } from '../../app/workspaceUtils';
|
|
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
|
|
|
|
const EntryType = Object.freeze({
|
|
document: 'document',
|
|
folder: 'folder',
|
|
});
|
|
|
|
const noop = () => { };
|
|
|
|
import type { Document } from '../../types/documents';
|
|
|
|
interface TenantOption {
|
|
id?: Identifier | null;
|
|
name?: string | null;
|
|
slug?: string | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface UseDocumentsWorkspaceOptions {
|
|
documentsViewMode?: string;
|
|
documentsSortField?: string;
|
|
documentsSortDirection?: string;
|
|
onDocumentsViewModeChange?: (mode: string) => void;
|
|
onDocumentsSortFieldChange?: (field: string) => void;
|
|
onDocumentsSortDirectionToggle?: () => void;
|
|
searchIncludeDescendants?: boolean;
|
|
onSetSearchIncludeDescendants?: (value: boolean) => void;
|
|
}
|
|
|
|
const useDocumentsWorkspace = ({
|
|
documentsViewMode = 'list',
|
|
documentsSortField = DEFAULT_SORT_FIELD,
|
|
documentsSortDirection = DEFAULT_SORT_DIRECTION,
|
|
onDocumentsViewModeChange,
|
|
onDocumentsSortFieldChange,
|
|
onDocumentsSortDirectionToggle,
|
|
searchIncludeDescendants = true,
|
|
onSetSearchIncludeDescendants,
|
|
}: UseDocumentsWorkspaceOptions = {}) => {
|
|
const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop;
|
|
const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop;
|
|
const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop;
|
|
const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop;
|
|
|
|
const activeSortFieldRef = useRef(documentsSortField);
|
|
useEffect(() => {
|
|
activeSortFieldRef.current = documentsSortField;
|
|
}, [documentsSortField]);
|
|
|
|
const activeSortDirectionRef = useRef(documentsSortDirection);
|
|
useEffect(() => {
|
|
activeSortDirectionRef.current = documentsSortDirection;
|
|
}, [documentsSortDirection]);
|
|
|
|
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 handleBreadcrumbNavigate = useCallback((crumb: { id?: Identifier | string } | null) => {
|
|
if (!crumb || !crumb.id) {
|
|
return;
|
|
}
|
|
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
|
|
navigate(target);
|
|
}, [navigate]);
|
|
|
|
const {
|
|
status: appStatus,
|
|
token,
|
|
tenant,
|
|
tenants: tenantOptionsRaw = [],
|
|
} = appState;
|
|
const { client: apiClient } = useApi();
|
|
|
|
const tenantRecord = (tenant ?? null) as TenantOption | null;
|
|
const currentTenantId: Identifier | null = (tenantRecord?.id ?? null) as Identifier | null;
|
|
|
|
const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw)
|
|
? (tenantOptionsRaw as TenantOption[])
|
|
: [];
|
|
const { showToast } = useStatusToast();
|
|
const notifyApiError = useNotifyApiError();
|
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
|
const { handleLogout } = useAuthManager({});
|
|
|
|
const tenantIdRef = useRef(currentTenantId);
|
|
const detailPanelControlRef = useRef({ open: () => { }, close: () => { } });
|
|
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<FolderNodeId | null>(null);
|
|
const [activePreviewId, setActivePreviewId] = useState<DocumentId | null>(routeDocumentId || null);
|
|
const shellRef = useRef(null);
|
|
const assetManagerRef = useRef(null);
|
|
if (!assetManagerRef.current) {
|
|
const fetcher = async (id: Identifier) => {
|
|
const asset = await fetchAsset(id);
|
|
return (asset as unknown) as any;
|
|
};
|
|
assetManagerRef.current = new AssetManager({ fetchAsset: fetcher });
|
|
}
|
|
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 selectionState = useWorkspaceSelection();
|
|
|
|
const {
|
|
selectedEntries,
|
|
selectedDocumentIds,
|
|
selectedFolderIds,
|
|
setSelectedEntries,
|
|
setSelectionOrder,
|
|
selectionOrderRef,
|
|
selectionAnchorRef,
|
|
selectionInitializedRef,
|
|
focusedDocumentId,
|
|
setFocusedDocumentId,
|
|
focusedEntryKey,
|
|
setFocusedEntryKey,
|
|
applySelection,
|
|
handleEntrySelection,
|
|
clearSelection,
|
|
promoteSelectionOrder: promoteSelectionOrderRaw,
|
|
configureSelectionEnvironment,
|
|
} = selectionState;
|
|
|
|
const {
|
|
documents,
|
|
setDocuments,
|
|
removeDocumentsFromLookup,
|
|
mapDocumentCaches,
|
|
updateDocumentCaches,
|
|
documentsManager,
|
|
} = useDocuments({
|
|
fetchDocumentById,
|
|
});
|
|
|
|
const documentLookup = useSyncExternalStore(
|
|
(onStoreChange) => documentsManager.subscribe(onStoreChange),
|
|
() => documentsManager.getSnapshot(),
|
|
() => documentsManager.getSnapshot(),
|
|
);
|
|
|
|
const foldersManagerRef = useRef<FoldersManager | null>(null);
|
|
if (!foldersManagerRef.current) {
|
|
foldersManagerRef.current = new FoldersManager();
|
|
}
|
|
const foldersManager = foldersManagerRef.current;
|
|
|
|
const folderStateRaw = useFolderTree({
|
|
initialSelectedFolder: routeFolderId || 'root',
|
|
foldersManager,
|
|
});
|
|
const {
|
|
folderNodes,
|
|
setFolderNodes,
|
|
selectedFolder,
|
|
setSelectedFolder,
|
|
currentFolderName,
|
|
folderOptions,
|
|
isInvalidFolderDrop,
|
|
} = folderStateRaw;
|
|
|
|
const folderState = {
|
|
...folderStateRaw,
|
|
setCreatingFolder,
|
|
};
|
|
|
|
const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]);
|
|
|
|
const reconcileSelectionWithFolderData = useCallback(
|
|
(currentSelection: string[], docs: Document[], subfolders: any[]) => {
|
|
const availableDocKeys = docs
|
|
.map((doc) => createDocumentEntryKey(doc?.id as Identifier))
|
|
.filter(Boolean);
|
|
const availableDocKeySet = new Set(availableDocKeys);
|
|
const availableFolderKeys = new Set(
|
|
subfolders
|
|
.map((folder) => createFolderEntryKey(folder?.id as Identifier))
|
|
.filter(Boolean),
|
|
);
|
|
|
|
const previousFolderKeys = currentSelection
|
|
.filter(isFolderEntry)
|
|
.filter((key) => availableFolderKeys.has(key));
|
|
const previousDocKeys = currentSelection.filter(isDocumentEntry);
|
|
const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
|
|
return [...previousFolderKeys, ...nextDocKeys];
|
|
},
|
|
[],
|
|
);
|
|
|
|
const selectedFolderRef = useRef<FolderNodeId>(selectedFolder);
|
|
useEffect(() => {
|
|
selectedFolderRef.current = selectedFolder;
|
|
}, [selectedFolder]);
|
|
|
|
const updateViewState = useCallback(
|
|
(folderId: FolderNodeId, data: any, includeDocuments: boolean) => {
|
|
// Guard against race conditions: only update if the folder is still selected
|
|
if (folderId === selectedFolderRef.current) {
|
|
if (includeDocuments) {
|
|
setDocuments((data.documents || []) as Document[]);
|
|
}
|
|
setCurrentSubfolders((data.subfolders || []) as any[]);
|
|
|
|
if (includeDocuments) {
|
|
setSelectedEntries((prev) => reconcileSelectionWithFolderData(
|
|
prev,
|
|
(data.documents || []) as Document[],
|
|
(data.subfolders || []) as any[]
|
|
));
|
|
}
|
|
}
|
|
},
|
|
[
|
|
setDocuments,
|
|
setSelectedEntries,
|
|
setCurrentSubfolders,
|
|
reconcileSelectionWithFolderData,
|
|
selectedFolderRef,
|
|
]
|
|
);
|
|
|
|
const fetchFolderData = useCallback(
|
|
async (
|
|
folderId: FolderNodeId,
|
|
options: { includeDocuments?: boolean } = {}
|
|
) => {
|
|
const path = folderId === 'root' ? 'root' : folderId;
|
|
const includeDocuments = options.includeDocuments ?? true;
|
|
const params: Record<string, unknown> = {
|
|
include_documents: includeDocuments,
|
|
sort: activeSortFieldRef.current,
|
|
dir: activeSortDirectionRef.current,
|
|
};
|
|
|
|
const data = await listFolderContents(path, params);
|
|
return { data, includeDocuments };
|
|
},
|
|
[
|
|
activeSortFieldRef,
|
|
activeSortDirectionRef,
|
|
]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (selectedFolder) {
|
|
fetchFolderData(selectedFolder)
|
|
.then(({ data, includeDocuments }) => {
|
|
updateViewState(selectedFolder, data, includeDocuments);
|
|
})
|
|
.catch((error) => {
|
|
notifyApiError(error, 'Failed to fetch folder contents');
|
|
});
|
|
}
|
|
}, [selectedFolder, documentsSortField, documentsSortDirection, fetchFolderData, updateViewState, notifyApiError]);
|
|
|
|
const {
|
|
searchQuery,
|
|
setSearchQuery,
|
|
searchResultIds,
|
|
setSearchResultIds,
|
|
searchLoading,
|
|
activeTagFilters,
|
|
setActiveTagFilters,
|
|
activeCorrespondentFilters,
|
|
setActiveCorrespondentFilters,
|
|
isFilterActive,
|
|
documentsFilterValue,
|
|
} = useDocumentsSearch({
|
|
api: apiClient,
|
|
selectedFolder,
|
|
locationPathname: location.pathname,
|
|
isDocumentsRoute,
|
|
searchIncludeDescendants,
|
|
documentsSortField,
|
|
documentsSortDirection,
|
|
setSearchIncludeDescendants,
|
|
documentsManager,
|
|
});
|
|
|
|
const documentsFilter = documentsFilterValue;
|
|
const showingSearchResults = searchResultIds !== null;
|
|
|
|
const {
|
|
viewDocuments,
|
|
visibleEntryKeySet,
|
|
} = useWorkspaceViewData({
|
|
documents,
|
|
documentLookup,
|
|
searchResultIds,
|
|
showingSearchResults,
|
|
currentSubfolders,
|
|
});
|
|
|
|
const {
|
|
openDocumentPreview,
|
|
closeDocumentPreview,
|
|
resetPreviewState,
|
|
} = useDocumentPreview({
|
|
routeDocumentId: previewDocumentId,
|
|
documentsManager,
|
|
selectedFolder,
|
|
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());
|
|
|
|
useWorkspaceSelectionSync({
|
|
showingSearchResults,
|
|
searchQuery,
|
|
setSelectedEntries,
|
|
setSelectionOrder,
|
|
selectionOrderRef,
|
|
selectionAnchorRef,
|
|
setFocusedDocumentId,
|
|
selectedDocumentIds,
|
|
activePreviewId,
|
|
setActivePreviewId,
|
|
selectionInitializedRef,
|
|
});
|
|
|
|
const tagsStateRaw = useTags({
|
|
tenantIdRef,
|
|
tagManager,
|
|
setActiveTagFilters,
|
|
mapDocumentCaches,
|
|
});
|
|
const {
|
|
tags,
|
|
refreshTags,
|
|
handleTagCreate,
|
|
handleTagUpdate,
|
|
handleTagDelete,
|
|
setTags,
|
|
} = tagsStateRaw;
|
|
|
|
// tagLookupById is derived locally
|
|
useEffect(() => {
|
|
tenantIdRef.current = currentTenantId;
|
|
}, [currentTenantId, tenantIdRef]);
|
|
|
|
const tagLookupById = useMemo(() => {
|
|
const map = new Map();
|
|
tags.forEach((tag) => {
|
|
if (tag?.id) {
|
|
map.set(tag.id, tag);
|
|
}
|
|
});
|
|
return map;
|
|
}, [tags]);
|
|
|
|
const tagsState = {
|
|
...tagsStateRaw,
|
|
tagLookupById, // Add derived lookup
|
|
tagManager,
|
|
};
|
|
|
|
const correspondentsStateRaw = useCorrespondents({
|
|
tenantIdRef,
|
|
mapDocumentCaches,
|
|
});
|
|
const {
|
|
correspondents,
|
|
refreshCorrespondents,
|
|
handleCorrespondentCreate,
|
|
handleCorrespondentUpdate,
|
|
handleCorrespondentDelete,
|
|
setCorrespondents,
|
|
} = correspondentsStateRaw;
|
|
|
|
const {
|
|
correspondentLookupByName,
|
|
handleDocumentCorrespondentAttach,
|
|
handleCorrespondentRemove,
|
|
handleCorrespondentAdd,
|
|
} = useDocumentCorrespondentActions({
|
|
correspondents,
|
|
handleCorrespondentCreate,
|
|
updateDocumentCaches,
|
|
});
|
|
|
|
const {
|
|
passkeys,
|
|
passkeysSupported,
|
|
passkeysLoading,
|
|
registeringPasskey,
|
|
revokingPasskeyId,
|
|
refreshPasskeys,
|
|
registerPasskey,
|
|
revokePasskey,
|
|
} = usePasskeys({});
|
|
|
|
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 refreshFolderData = useCallback(async () => {
|
|
if (selectedFolder) {
|
|
const { data, includeDocuments } = await fetchFolderData(selectedFolder);
|
|
updateViewState(selectedFolder, data, includeDocuments);
|
|
}
|
|
}, [selectedFolder, fetchFolderData, updateViewState]);
|
|
|
|
const handleManualRefresh = useCallback(async () => {
|
|
try {
|
|
await refreshFolderData();
|
|
showToast('Folder refreshed successfully', 'success');
|
|
} catch (error) {
|
|
showToast('Failed to refresh folder', 'error');
|
|
console.error('Failed to refresh folder:', error);
|
|
}
|
|
}, [refreshFolderData, showToast]);
|
|
|
|
const {
|
|
handleBulkTagAddFromDetail,
|
|
handleBulkTagRemoveFromDetail,
|
|
handleBulkSelectionReanalyze,
|
|
} = useDocumentTagActions({
|
|
tags,
|
|
tagManager,
|
|
refreshTags,
|
|
resolveTargetDocumentIds,
|
|
updateDocumentCaches,
|
|
});
|
|
|
|
const {
|
|
dropOverlayState,
|
|
handleFileDrop,
|
|
uploadQueue,
|
|
clearUploadQueue,
|
|
resetUploadsState,
|
|
handleFileSelection,
|
|
} = useDocumentUploads({
|
|
selectedFolder,
|
|
currentFolderName,
|
|
refreshCurrentFolder: refreshFolderData,
|
|
shellRef,
|
|
});
|
|
|
|
const {
|
|
handleDocumentDragStart,
|
|
handleDocumentDragEnd,
|
|
handleFolderDragStart,
|
|
handleFolderDragEnd,
|
|
} = useDocumentDragHandlers({
|
|
documentLookup,
|
|
setDraggedDocumentIds,
|
|
setDraggedFolderId,
|
|
documentsViewMode,
|
|
selectedEntries,
|
|
selectedDocumentIds,
|
|
selectedFolderIds,
|
|
applySelection,
|
|
handleEntrySelection,
|
|
});
|
|
|
|
const resetWorkspaceState = useCallback(() => {
|
|
const rootNode = createRootNode();
|
|
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
|
setSelectedFolder('root');
|
|
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();
|
|
|
|
detailFolderFetchRef.current = new Set();
|
|
bootstrapInitializedRef.current = false;
|
|
selectionInitializedRef.current = false;
|
|
tenantIdRef.current = null;
|
|
}, [
|
|
assetManager,
|
|
selectionAnchorRef,
|
|
selectionInitializedRef,
|
|
selectionOrderRef,
|
|
setFocusedDocumentId,
|
|
setSelectedEntries,
|
|
setSelectionOrder,
|
|
setFolderNodes,
|
|
setSelectedFolder,
|
|
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;
|
|
});
|
|
|
|
removeDocumentsFromLookup(Array.from(idSet));
|
|
},
|
|
[
|
|
setDocuments,
|
|
setSearchResultIds,
|
|
removeDocumentsFromLookup,
|
|
],
|
|
);
|
|
|
|
const documentsState = {
|
|
documentLookup,
|
|
setDocuments,
|
|
setSearchResultIds,
|
|
removeDocumentsFromCaches,
|
|
updateDocumentCaches,
|
|
mapDocumentCaches,
|
|
extractDocumentFromResponse,
|
|
ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs),
|
|
};
|
|
|
|
const actionsState = {
|
|
closeDocumentPreview,
|
|
};
|
|
|
|
const {
|
|
moveDocumentsToFolder,
|
|
handleThumbnailRegeneration,
|
|
handleDocumentsDelete,
|
|
handleDocumentTagAdd,
|
|
handleDocumentTagAttach,
|
|
handleDocumentTitleUpdate,
|
|
handleDocumentIssuedUpdate,
|
|
handleDocumentTagDetach,
|
|
} = useDocumentMutations({
|
|
documentsState,
|
|
folderState,
|
|
selectionState,
|
|
tagsState,
|
|
actions: actionsState,
|
|
previewDocumentId,
|
|
});
|
|
|
|
const dragState = {
|
|
draggedDocumentIds,
|
|
draggedFolderId,
|
|
setDraggedDocumentIds,
|
|
setDraggedFolderId,
|
|
};
|
|
|
|
const {
|
|
loadFolder,
|
|
selectFolder,
|
|
handleFolderRename,
|
|
handleFolderCreate,
|
|
handleFolderDelete,
|
|
folderClickHandlers,
|
|
} = useFolderTreeActions({
|
|
folderState,
|
|
dragState,
|
|
actions: {
|
|
handleFileDrop,
|
|
moveDocumentsToFolder,
|
|
},
|
|
utils: {
|
|
isInvalidFolderDrop,
|
|
},
|
|
});
|
|
|
|
const {
|
|
clearDocumentSelection,
|
|
} = useDocumentsSelection({
|
|
showingSearchResults,
|
|
currentSubfolders,
|
|
visibleDocuments: viewDocuments,
|
|
configureSelectionEnvironment,
|
|
visibleEntryKeySet,
|
|
selectedEntries,
|
|
selectionAnchorRef,
|
|
promoteSelectionOrderRaw,
|
|
setFocusedDocumentId,
|
|
setActivePreviewId,
|
|
clearSelection,
|
|
focusedDocumentId,
|
|
setFocusedEntryKey,
|
|
focusedEntryKey,
|
|
});
|
|
const initializeAfterLogin = useCallback(async () => {
|
|
await Promise.all([
|
|
refreshTags(),
|
|
refreshCorrespondents(),
|
|
foldersManager.ensureTree(),
|
|
]);
|
|
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
|
await loadFolder(initialFolder, {});
|
|
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, foldersManager]);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
if (appStatus !== 'ready') {
|
|
return;
|
|
}
|
|
|
|
const targetParam = routeFolderId ?? 'root';
|
|
|
|
if (targetParam === 'root' && routeDocumentId) {
|
|
return;
|
|
}
|
|
|
|
// Checking cache (folderContents) is removed, now we rely on selectedFolder effect to fetch.
|
|
if (targetParam !== selectedFolder) {
|
|
selectFolder(targetParam, { immediate: true });
|
|
}
|
|
}, [
|
|
token,
|
|
appStatus,
|
|
routeFolderId,
|
|
routeDocumentId,
|
|
selectedFolder,
|
|
isFilterActive,
|
|
selectFolder,
|
|
]);
|
|
|
|
const mountedRef = useRef(true);
|
|
useEffect(() => {
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (appStatus !== 'authenticated') {
|
|
return;
|
|
}
|
|
if (bootstrapInitializedRef.current) {
|
|
return;
|
|
}
|
|
|
|
const bootstrap = async () => {
|
|
bootstrapInitializedRef.current = true;
|
|
appDispatch({ type: 'BOOTSTRAP_START' });
|
|
try {
|
|
await initializeAfterLogin();
|
|
if (mountedRef.current) {
|
|
appDispatch({ type: 'BOOTSTRAP_SUCCESS' });
|
|
}
|
|
} catch (error) {
|
|
if (mountedRef.current) {
|
|
appDispatch({
|
|
type: 'BOOTSTRAP_FAILURE',
|
|
error: error?.message || 'Failed to initialize data.',
|
|
});
|
|
bootstrapInitializedRef.current = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
bootstrap();
|
|
}, [appStatus, appDispatch, initializeAfterLogin]);
|
|
|
|
const {
|
|
handleBulkCorrespondentAdd,
|
|
handleBulkCorrespondentRemove,
|
|
handleDeleteSelection,
|
|
} = useBulkDocumentActions({
|
|
resolveTargetDocumentIds,
|
|
correspondentLookupByName,
|
|
handleCorrespondentCreate,
|
|
selectedDocumentIds,
|
|
selectedFolderIds,
|
|
handleDocumentsDelete,
|
|
handleFolderDelete,
|
|
clearDocumentSelection,
|
|
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 handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
|
|
if (creatingFolder) {
|
|
return;
|
|
}
|
|
const input = window.prompt('New folder name');
|
|
if (!input) {
|
|
return;
|
|
}
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
showToast('Folder name cannot be empty.', 'error');
|
|
return;
|
|
}
|
|
setCreatingFolder(true);
|
|
try {
|
|
const success = await handleFolderCreate(trimmed, parentId);
|
|
if (!success) {
|
|
showToast('Unable to create folder. Check the status message for details.', 'error');
|
|
}
|
|
} finally {
|
|
setCreatingFolder(false);
|
|
}
|
|
}, [creatingFolder, handleFolderCreate, showToast]);
|
|
|
|
const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({
|
|
locationPathname: location.pathname,
|
|
tags,
|
|
refreshTags,
|
|
onTagCreate: handleTagCreate,
|
|
onTagUpdate: handleTagUpdate,
|
|
onTagDelete: handleTagDelete,
|
|
correspondents,
|
|
refreshCorrespondents,
|
|
onCorrespondentCreate: handleCorrespondentCreate,
|
|
onCorrespondentUpdate: handleCorrespondentUpdate,
|
|
onCorrespondentDelete: handleCorrespondentDelete,
|
|
});
|
|
|
|
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]);
|
|
|
|
|
|
|
|
const {
|
|
detailPanelProps,
|
|
detailPanelOpen,
|
|
openDetailPanel,
|
|
previewActive,
|
|
previewWorkspaceDocument,
|
|
resolveFolderPath,
|
|
} = useDetailWorkspace({
|
|
documents: viewDocuments,
|
|
documentLookup,
|
|
folderNodes,
|
|
detailPanelControlRef,
|
|
detailFolderFetchRef,
|
|
previewDocumentId,
|
|
activePreviewId,
|
|
openDocumentPreview: openDocumentPreviewForDetail,
|
|
handleDocumentTitleUpdate,
|
|
handleDocumentIssuedUpdate,
|
|
handleDocumentTagAdd,
|
|
handleDocumentTagDetach,
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
correspondents,
|
|
handleCorrespondentAdd,
|
|
handleCorrespondentRemove,
|
|
selectFolder,
|
|
tags,
|
|
tagLookupById,
|
|
});
|
|
|
|
const handleEntryPointerCore = useEntryPointerCore({
|
|
onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => {
|
|
const { type, id } = entry;
|
|
const key = rowKey
|
|
|| (type === EntryType.document ? createDocumentEntryKey(id) : createFolderEntryKey(id));
|
|
if (key) {
|
|
if (documentsViewMode === 'grid' && (event as any).shiftKey) {
|
|
// Additive selection for Shift+Click in Grid View
|
|
const newSelection = Array.from(new Set([...selectedEntries, key]));
|
|
applySelection(newSelection, { anchor: key, interactedKeys: [key] });
|
|
} else {
|
|
handleEntrySelection(key, event);
|
|
}
|
|
}
|
|
if (type === EntryType.folder && !modifierClick && primaryClick) {
|
|
selectFolder(id);
|
|
}
|
|
},
|
|
});
|
|
|
|
const breadcrumbs = useMemo(() => {
|
|
return resolveBreadcrumbs(selectedFolder || 'root', folderNodes as any);
|
|
}, [selectedFolder, folderNodes]);
|
|
|
|
const { handleTenantSelect } = useTenantManager({
|
|
currentTenantId,
|
|
handleDocumentsViewModeChange,
|
|
});
|
|
|
|
const sessionContext = {
|
|
token,
|
|
appStatus,
|
|
handleLogout,
|
|
tenant: tenantRecord,
|
|
tenants: tenantOptions,
|
|
tenantOptions,
|
|
handleTenantSelect,
|
|
};
|
|
|
|
const uiContext = {
|
|
notifyApiError,
|
|
settingsOpen,
|
|
openSettings,
|
|
closeSettings,
|
|
managementModals,
|
|
refreshCurrentFolder: handleManualRefresh,
|
|
};
|
|
|
|
const uploadContext = {
|
|
dropOverlayState,
|
|
uploadQueue,
|
|
clearUploadQueue,
|
|
handleFileSelection,
|
|
};
|
|
|
|
const tagsContext = {
|
|
tags,
|
|
refreshTags,
|
|
tagLookupById,
|
|
activeTagFilters,
|
|
handleTagUpdate,
|
|
handleTagDelete,
|
|
handleDocumentTagAttach,
|
|
handleDocumentTagDetach,
|
|
handleBulkTagAddFromDetail,
|
|
handleBulkTagRemoveFromDetail,
|
|
openTagsModal,
|
|
};
|
|
|
|
const correspondentsContext = {
|
|
correspondents,
|
|
refreshCorrespondents,
|
|
activeCorrespondentFilters,
|
|
handleCorrespondentUpdate,
|
|
handleCorrespondentCreate,
|
|
handleCorrespondentDelete,
|
|
handleDocumentCorrespondentAttach,
|
|
handleCorrespondentRemove,
|
|
handleCorrespondentAdd,
|
|
handleBulkCorrespondentAdd,
|
|
handleBulkCorrespondentRemove,
|
|
openCorrespondentsModal,
|
|
};
|
|
|
|
const passkeysContext = {
|
|
passkeys,
|
|
passkeysSupported,
|
|
passkeysLoading,
|
|
registeringPasskey,
|
|
revokingPasskeyId,
|
|
refreshPasskeys,
|
|
registerPasskey,
|
|
revokePasskey,
|
|
};
|
|
|
|
const previewContext = {
|
|
previewActive,
|
|
previewWorkspaceDocument,
|
|
previewDocumentId,
|
|
closeDocumentPreview,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
handleThumbnailRegeneration,
|
|
};
|
|
|
|
const detailPanelContext = {
|
|
detailPanelProps,
|
|
detailPanelOpen,
|
|
openDetailPanel,
|
|
};
|
|
|
|
const searchContext = {
|
|
searchQuery,
|
|
documentsFilter,
|
|
searchLoading,
|
|
documentsViewMode,
|
|
handleDocumentsViewModeChange,
|
|
documentsSortField,
|
|
documentsSortDirection,
|
|
handleDocumentsSortFieldChange,
|
|
handleDocumentsSortDirectionToggle,
|
|
searchResultIds,
|
|
documents: viewDocuments,
|
|
};
|
|
|
|
const folderTreeContext = {
|
|
foldersManager,
|
|
selectedFolder,
|
|
currentFolderName,
|
|
folderOptions,
|
|
handleBreadcrumbNavigate,
|
|
resolveFolderPath,
|
|
selectFolder,
|
|
moveDocumentsToFolder,
|
|
folderClickHandlers,
|
|
handleFolderRename,
|
|
handleFolderDelete,
|
|
handleFolderDragStart,
|
|
handleFolderDragEnd,
|
|
draggedFolderId,
|
|
handlePromptCreateFolder,
|
|
creatingFolder,
|
|
currentSubfolders,
|
|
breadcrumbs,
|
|
};
|
|
|
|
const selectionContext = {
|
|
clearDocumentSelection,
|
|
handleDeleteSelection,
|
|
handleEntryPointerCore,
|
|
handleBulkSelectionReanalyze,
|
|
selectionValue: selectionState,
|
|
};
|
|
|
|
const documentMutations = {
|
|
handleDocumentTitleUpdate,
|
|
handleDocumentDragStart,
|
|
handleDocumentDragEnd,
|
|
draggedDocumentIds,
|
|
};
|
|
|
|
const managers = {
|
|
documentsManager,
|
|
documentLookup,
|
|
};
|
|
|
|
const contextValue = {
|
|
...sessionContext,
|
|
...uiContext,
|
|
...uploadContext,
|
|
...tagsContext,
|
|
...correspondentsContext,
|
|
...passkeysContext,
|
|
...previewContext,
|
|
...detailPanelContext,
|
|
...searchContext,
|
|
...folderTreeContext,
|
|
...selectionContext,
|
|
...documentMutations,
|
|
...managers,
|
|
};
|
|
|
|
// hook callers handle rendering / routing
|
|
return {
|
|
appStatus,
|
|
location,
|
|
shellRef,
|
|
dropOverlayState,
|
|
managementModals,
|
|
contextValue,
|
|
settingsOpen,
|
|
closeSettings,
|
|
};
|
|
};
|
|
|
|
export default useDocumentsWorkspace;
|