Files
papercrate/frontend/src/documents/data/useDocumentsWorkspace.ts
T

1157 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 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,
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,
selectedFolder,
setSelectedFolder,
currentFolderName,
folderOptions,
isInvalidFolderDrop,
} = folderStateRaw;
const folderState = {
...folderStateRaw,
setCreatingFolder,
foldersManager,
};
const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]);
const foldersSnapshot = useSyncExternalStore(
useCallback((cb) => foldersManager.subscribe(cb), [foldersManager]),
() => foldersManager.getSnapshot(),
() => foldersManager.getSnapshot(),
);
const visibleSubfolders = useMemo(() => {
return currentSubfolders.map((folder) => {
if (!folder?.id) return folder;
const live = foldersSnapshot.get(folder.id);
if (live) {
return { ...folder, ...live };
}
return folder;
});
}, [currentSubfolders, foldersSnapshot]);
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[]);
}
const subfolders = (data.subfolders || []) as any[];
setCurrentSubfolders(subfolders);
foldersManager.ingest(subfolders);
if (includeDocuments) {
setSelectedEntries((prev) => reconcileSelectionWithFolderData(
prev,
(data.documents || []) as Document[],
(data.subfolders || []) as any[]
));
}
}
},
[
setDocuments,
setSelectedEntries,
setCurrentSubfolders,
reconcileSelectionWithFolderData,
selectedFolderRef,
foldersManager,
]
);
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;
// Live Filter: Ensure we only show documents that actually belong to the current folder.
// Since 'documents' is reactive, if a document is moved, its folder_id updates immediately.
// We must filter out any documents that no longer match the selectedFolder.
const liveFilteredDocuments = useMemo(() => {
if (showingSearchResults) {
return documents;
}
const targetFolder = selectedFolder || 'root';
return documents.filter((doc) => {
if (!doc) return false;
const docFolder = doc.folder_id || 'root';
return docFolder === targetFolder;
});
}, [documents, showingSearchResults, selectedFolder]);
const {
viewDocuments,
visibleEntryKeySet,
} = useWorkspaceViewData({
documents: liveFilteredDocuments,
documentLookup,
searchResultIds,
showingSearchResults,
currentSubfolders: visibleSubfolders,
});
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,
documentsManager,
});
const {
tags,
refreshTags,
handleTagCreate,
handleTagUpdate,
handleTagDelete,
setTags,
} = tagsStateRaw;
// tagLookupById is derived locally
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId, tenantIdRef]);
const tagLookupById = new Map();
tags.forEach((tag) => {
if (tag?.id) {
tagLookupById.set(tag.id, tag);
}
});
const tagsState = {
...tagsStateRaw,
tagLookupById, // Add derived lookup
tagManager,
};
const correspondentsStateRaw = useCorrespondents({
tenantIdRef,
documentsManager,
});
const {
correspondents,
refreshCorrespondents,
handleCorrespondentCreate,
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
} = correspondentsStateRaw;
const {
correspondentLookupByName,
handleDocumentCorrespondentAttach,
handleCorrespondentRemove,
handleCorrespondentAdd,
} = useDocumentCorrespondentActions({
correspondents,
handleCorrespondentCreate,
documentsManager,
});
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,
documentsManager,
});
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(() => {
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,
setSelectedFolder,
setCurrentSubfolders,
setDocuments,
setDraggedDocumentIds,
setDraggedFolderId,
setSearchResultIds,
setTags,
setCorrespondents,
setSearchQuery,
setActiveTagFilters,
setActiveCorrespondentFilters,
setActivePreviewId,
resetPreviewState,
resetUploadsState,
clearUploadQueue,
]);
useEffect(() => {
if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
foldersManager.invalidateTree();
resetWorkspaceState();
}
}, [appStatus, resetWorkspaceState, foldersManager]);
const documentsState = {
documentLookup,
setDocuments,
setSearchResultIds,
documentsManager,
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: visibleSubfolders,
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,
documentsManager,
});
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;
}
documentsManager.map((doc) => {
if (doc.id !== documentId) return undefined;
return mergeAssetIntoDocument(doc, entry);
});
return entry;
} catch (error) {
notifyApiError(error, 'Unable to refresh document asset.');
throw error;
}
},
[assetManager, documentsManager, 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: visibleSubfolders,
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;