From bc33f682099c8dadb78407d14a8da6d0d0cf71a1 Mon Sep 17 00:00:00 2001
From: Nils Schneider
Date: Sun, 9 Nov 2025 13:03:44 +0100
Subject: [PATCH] cleanup
---
frontend/src/app/AppLayout.jsx | 5248 +----------------
frontend/src/app/DocumentsAppLayout.jsx | 64 +
frontend/src/app/appLayoutUtils.js | 132 +
frontend/src/app/appState.js | 7 +-
frontend/src/app/useDocumentPreview.js | 221 +
frontend/src/app/useDocumentsPreferences.js | 138 +
frontend/src/app/useDocumentsSearch.js | 238 +
frontend/src/app/useDocumentsWorkspace.js | 1 +
frontend/src/desktop/useDeskWorkspaceProps.js | 141 +
.../documents/store/useDocumentsStore.js | 13 +
.../src/hooks/documents/useAuthManager.js | 137 +
.../src/hooks/documents/useCorrespondents.js | 127 +
.../useDocumentCorrespondentActions.js | 130 +
.../documents/useDocumentDragHandlers.js | 252 +
.../src/hooks/documents/useDocumentTagging.js | 204 +
.../src/hooks/documents/useDocumentUploads.js | 303 +
frontend/src/hooks/documents/useDocuments.js | 91 +
.../hooks/documents/useDocumentsWorkspace.js | 2674 +++++++++
frontend/src/hooks/documents/useFileDrop.js | 84 +
frontend/src/hooks/documents/useFolderTree.js | 432 ++
frontend/src/hooks/documents/useTags.js | 125 +
.../src/hooks/documents/useTenantManager.js | 104 +
frontend/src/lib/api.js | 8 +
.../settings/sections/ApiTokensSection.jsx | 52 +-
24 files changed, 5665 insertions(+), 5261 deletions(-)
create mode 100644 frontend/src/app/DocumentsAppLayout.jsx
create mode 100644 frontend/src/app/appLayoutUtils.js
create mode 100644 frontend/src/app/useDocumentPreview.js
create mode 100644 frontend/src/app/useDocumentsPreferences.js
create mode 100644 frontend/src/app/useDocumentsSearch.js
create mode 100644 frontend/src/app/useDocumentsWorkspace.js
create mode 100644 frontend/src/desktop/useDeskWorkspaceProps.js
create mode 100644 frontend/src/hooks/documents/store/useDocumentsStore.js
create mode 100644 frontend/src/hooks/documents/useAuthManager.js
create mode 100644 frontend/src/hooks/documents/useCorrespondents.js
create mode 100644 frontend/src/hooks/documents/useDocumentCorrespondentActions.js
create mode 100644 frontend/src/hooks/documents/useDocumentDragHandlers.js
create mode 100644 frontend/src/hooks/documents/useDocumentTagging.js
create mode 100644 frontend/src/hooks/documents/useDocumentUploads.js
create mode 100644 frontend/src/hooks/documents/useDocuments.js
create mode 100644 frontend/src/hooks/documents/useDocumentsWorkspace.js
create mode 100644 frontend/src/hooks/documents/useFileDrop.js
create mode 100644 frontend/src/hooks/documents/useFolderTree.js
create mode 100644 frontend/src/hooks/documents/useTags.js
create mode 100644 frontend/src/hooks/documents/useTenantManager.js
create mode 100644 frontend/src/lib/api.js
diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx
index c2560e6..3f6cd7a 100644
--- a/frontend/src/app/AppLayout.jsx
+++ b/frontend/src/app/AppLayout.jsx
@@ -1,5247 +1 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- Navigate,
- Outlet,
- matchPath,
- useLocation,
- useMatch,
- useNavigate,
-} from 'react-router-dom';
-import AssetManager, {
- getAssetFromVersion,
- resolveDocumentAssetUrl,
- createAssetView,
-} from '../asset_manager';
-import useApiError from '../hooks/useApiError';
-import TagManager from '../tag_manager';
-import usePasskeys from '../settings/usePasskeys';
-import { AppShellContext } from '../appShellContext';
-import DropOverlay from './DropOverlay';
-import { useManagementModals } from './useManagementModals';
-import { api, useAppDispatch, useAppState } from './appState';
-import { useDetailPanel } from './useDetailPanel';
-import useWorkspaceSelection from './useWorkspaceSelection';
-import { useEntryPointerHandler as useEntryPointerCore } from '../documents/useEntryPointer';
-import { isTagTransferEvent } from '../documents/tagTransfer';
-
-const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
-
-const DEFAULT_FOLDER_NAME = 'Documents';
-const DEFAULT_SORT_FIELD = 'title';
-const DEFAULT_SORT_DIRECTION = 'asc';
-const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at'];
-const TAG_FILTER_UNTAGGED = '__UNTAGGED__';
-
-const ROW_KEY_SEPARATOR = ':';
-const DOCUMENT_ROW_PREFIX = 'document';
-const FOLDER_ROW_PREFIX = 'folder';
-
-const resolveApiPath = (path = '') => path;
-
-const makeRowKey = (type, id) =>
- id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
-
-const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
-
-const getRowId = (key) => {
- if (typeof key !== 'string') return '';
- const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
- if (separatorIndex === -1) return key;
- return key.slice(separatorIndex + 1);
-};
-
-const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
-const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX;
-
-const resolveDocumentRowKey = (documentId) =>
- documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null;
-
-const resolveFolderRowKey = (folderId) =>
- folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null;
-
-const hasFiles = (event) =>
- Array.from(event.dataTransfer?.types || []).includes('Files');
-
-const isAssetEquivalent = (lhs, rhs) => {
- if (!lhs || !rhs) return false;
- const lhsView = createAssetView(lhs);
- const rhsView = createAssetView(rhs);
- const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
- const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
- const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
- const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
- const lhsObjects = lhsView.getObjects();
- const rhsObjects = rhsView.getObjects();
- const objectsComparable =
- lhsObjects.length === rhsObjects.length
- && lhsObjects.every((entry, index) => {
- const other = rhsObjects[index];
- if (!other) return false;
- if (entry.ordinal !== other.ordinal) return false;
- if (entry.url && other.url && entry.url === other.url) {
- return true;
- }
- if (!entry.url && !other.url) {
- return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
- }
- return entry.url === other.url;
- });
- return (
- lhs.id === rhs.id
- && lhs.url === rhs.url
- && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
- && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
- && lhs.mime_type === rhs.mime_type
- && lhs.asset_type === rhs.asset_type
- && lhs.updated_at === rhs.updated_at
- && lhsCardinality === rhsCardinality
- && objectsComparable
- );
-};
-
-const mergeAssetIntoGroup = (group, assetData) => {
- if (!assetData || !assetData.asset_type) {
- if (Array.isArray(group)) {
- return group;
- }
- return group || {};
- }
-
- if (Array.isArray(group) || !group) {
- const list = Array.isArray(group) ? group : [];
- const index = list.findIndex((item) => item?.id === assetData.id);
- if (index >= 0) {
- const existing = list[index];
- if (isAssetEquivalent(existing, assetData)) {
- return list;
- }
- const next = list.slice();
- next[index] = { ...existing, ...assetData };
- return next;
- }
- return list.concat({ ...assetData });
- }
-
- const key = assetData.asset_type;
- const previous = group?.[key];
- if (previous && isAssetEquivalent(previous, assetData)) {
- return group;
- }
-
- const next = { ...(group || {}) };
- next[key] = { ...(previous || {}), ...assetData };
- return next;
-};
-
-const mergeAssetIntoDocument = (doc, assetData) => {
- if (!doc) return doc;
- const existingGroup = doc.current_version?.assets || null;
- const nextGroup = mergeAssetIntoGroup(existingGroup, assetData);
- if (nextGroup === existingGroup) {
- return doc;
- }
- const updatedCurrentVersion = doc.current_version
- ? { ...doc.current_version, assets: nextGroup }
- : { assets: nextGroup };
- return { ...doc, current_version: updatedCurrentVersion };
-};
-
-const createRootNode = () => ({
- id: 'root',
- name: DEFAULT_FOLDER_NAME,
- parentId: null,
- children: [],
- expanded: true,
- loaded: false,
- hasChildren: false,
-});
-
-const AppLayout = () => {
- 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: tenantOptions = [] } = appState;
- const tenantName = tenant?.name || tenant?.slug || null;
- const currentTenantId = tenant?.id || null;
- const [status, setStatus] = useState(null);
- const setStatusMessage = useCallback((message, variant = 'info') => {
- setStatus(message ? { message, variant } : null);
- }, []);
- 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 [folderNodes, setFolderNodes] = useState(() => {
- const rootNode = createRootNode();
- return new Map([[rootNode.id, rootNode]]);
- });
- const [folderContents, setFolderContents] = useState(() => new Map());
- const folderContentsRef = useRef(folderContents);
- const [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root');
- const [currentFolder, setCurrentFolder] = useState(null);
- const [currentSubfolders, setCurrentSubfolders] = useState([]);
- const [documents, setDocuments] = useState([]);
- const [documentsViewMode, setDocumentsViewMode] = useState(() => {
- if (typeof window === 'undefined') {
- return 'list';
- }
- try {
- const stored = window.sessionStorage.getItem('papercrate_view_mode');
- return stored === 'grid' || stored === 'desk' ? stored : 'list';
- } catch (error) {
- console.warn('[view-mode] failed to read stored mode', error);
- return 'list';
- }
- });
- const [documentsSortField, setDocumentsSortField] = useState(() => {
- if (typeof window === 'undefined') {
- return DEFAULT_SORT_FIELD;
- }
- try {
- const stored = window.sessionStorage.getItem('papercrate_sort_field');
- return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD;
- } catch (error) {
- console.warn('[sort] failed to read stored sort field', error);
- return DEFAULT_SORT_FIELD;
- }
- });
- const [documentsSortDirection, setDocumentsSortDirection] = useState(() => {
- if (typeof window === 'undefined') {
- return DEFAULT_SORT_DIRECTION;
- }
- try {
- const stored = window.sessionStorage.getItem('papercrate_sort_direction');
- return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION;
- } catch (error) {
- console.warn('[sort] failed to read stored sort direction', error);
- return DEFAULT_SORT_DIRECTION;
- }
- });
- const documentsSortFieldRef = useRef(DEFAULT_SORT_FIELD);
- const documentsSortDirectionRef = useRef(DEFAULT_SORT_DIRECTION);
- const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => {
- if (typeof window === 'undefined') {
- return true;
- }
- try {
- const stored = window.sessionStorage.getItem('papercrate_include_descendants');
- if (stored === 'true') return true;
- if (stored === 'false') return false;
- } catch (error) {
- console.warn('[search] failed to read include_descendants preference', error);
- }
- return true;
- });
- const sortRefreshReadyRef = useRef(false);
- const [deskHelpOpen, setDeskHelpOpen] = useState(false);
- const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
-
- useEffect(() => {
- if (documentsViewMode !== 'desk') {
- lastNonDeskViewRef.current = documentsViewMode;
- }
- }, [documentsViewMode]);
-
- useEffect(() => {
- if (documentsViewMode !== 'desk' && deskHelpOpen) {
- setDeskHelpOpen(false);
- }
- }, [documentsViewMode, deskHelpOpen]);
- useEffect(() => {
- documentsSortFieldRef.current = documentsSortField;
- if (typeof window !== 'undefined') {
- try {
- window.sessionStorage.setItem('papercrate_sort_field', documentsSortField);
- } catch (error) {
- console.warn('[sort] failed to persist sort field', error);
- }
- }
- }, [documentsSortField, documentsSortFieldRef]);
-
- useEffect(() => {
- documentsSortDirectionRef.current = documentsSortDirection;
- if (typeof window !== 'undefined') {
- try {
- window.sessionStorage.setItem('papercrate_sort_direction', documentsSortDirection);
- } catch (error) {
- console.warn('[sort] failed to persist sort direction', error);
- }
- }
- }, [documentsSortDirection, documentsSortDirectionRef]);
-
- useEffect(() => {
- if (typeof window === 'undefined') {
- return;
- }
- try {
- window.sessionStorage.setItem(
- 'papercrate_include_descendants',
- searchIncludeDescendants ? 'true' : 'false',
- );
- } catch (error) {
- console.warn('[search] failed to persist include_descendants preference', error);
- }
- }, [searchIncludeDescendants]);
-
- const tokenRef = useRef(token);
- const refreshPromiseRef = useRef(null);
- const breadcrumbFetchRef = useRef(new Set());
- const tagRemovalCursorActiveRef = useRef(false);
- const tenantIdRef = useRef(currentTenantId);
- const detailPanelControlRef = useRef({ open: () => {}, close: () => {} });
- const setTagRemovalCursor = useCallback((active) => {
- if (typeof document === 'undefined') {
- return;
- }
- 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 refreshAccessToken = useCallback(async () => {
- console.log('[Auth] Attempting to refresh access token…');
- appDispatch({ type: 'TOKEN_REFRESH_START' });
- try {
- const { data } = await api.post('/auth/refresh');
- if (data?.access_token) {
- appDispatch({
- type: 'TOKEN_REFRESH_SUCCESS',
- token: data.access_token,
- tenant: data.tenant || null,
- });
- console.log('[Auth] Access token refreshed at', new Date().toISOString());
- return data.access_token;
- }
- throw new Error('Missing access token in refresh response');
- } catch (error) {
- console.warn('[Auth] Failed to refresh access token', error);
- appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
- throw error;
- }
- }, [appDispatch]);
- const [searchResults, setSearchResults] = useState(null);
- const [previewEntries, setPreviewEntries] = useState(() => new Map());
- const previewInflightRef = useRef(new Map());
- const previewReturnPathRef = useRef(null);
- const [tags, setTags] = useState([]);
- const [correspondents, setCorrespondents] = useState([]);
- const [searchQuery, setSearchQuery] = useState('');
- const [activeTagFilters, setActiveTagFilters] = useState([]);
- const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
- const [searchLoading, setSearchLoading] = useState(false);
- const documentsRouteMatch = useMatch('/documents');
- const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
- const documentsDetailRouteMatch = useMatch('/documents/:documentId');
- const isDocumentsRoute = Boolean(
- documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
- );
- const toggleTagFilter = useCallback((tagId) => {
- if (!tagId) return;
- setActiveTagFilters((previous) => {
- if (tagId === TAG_FILTER_UNTAGGED) {
- return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
- }
- const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED);
- if (sanitized.includes(tagId)) {
- return sanitized.filter((id) => id !== tagId);
- }
- return sanitized.concat([tagId]);
- });
- }, []);
-
- const toggleCorrespondentFilter = useCallback((correspondentId) => {
- setActiveCorrespondentFilters((previous) => {
- if (!correspondentId) {
- return [];
- }
- return previous.includes(correspondentId) ? [] : [correspondentId];
- });
- }, []);
-
- const handleDocumentsSortFieldChange = useCallback((field) => {
- const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
- setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
- }, []);
-
- const handleDocumentsSortDirectionToggle = useCallback(() => {
- setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc'));
- }, []);
-
- const toggleSearchIncludeDescendants = useCallback(() => {
- setSearchIncludeDescendants((previous) => !previous);
- }, []);
-
- const initialRefreshAttemptedRef = useRef(Boolean(token));
-
- useEffect(() => {
- if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
- initialRefreshAttemptedRef.current = true;
- console.log('[Auth] Attempting refresh at startup');
- refreshAccessToken().catch(() => {});
- }
- }, [token, appStatus, refreshAccessToken]);
-
- const clearFilters = useCallback(() => {
- setSearchQuery('');
- setActiveTagFilters([]);
- setActiveCorrespondentFilters([]);
- setSearchLoading(false);
- setSearchIncludeDescendants(true);
- }, []);
-
- const handleSearchChange = useCallback((value) => {
- setSearchQuery(value);
- }, []);
-
- const handleSearchSubmit = useCallback(() => {
- if (!navigate) return;
- const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
- const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
- if (!isDocumentsRoute || location.pathname !== targetPath) {
- navigate(targetPath, { replace: false });
- }
- }, [navigate, selectedFolder, isDocumentsRoute, location.pathname]);
- const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
- const [draggedFolderId, setDraggedFolderId] = useState(null);
- const [dropOverlayState, setDropOverlayState] = useState({
- active: false,
- folderName: DEFAULT_FOLDER_NAME,
- });
- const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
- const shellRef = useRef(null);
- const assetManagerRef = useRef(null);
- if (!assetManagerRef.current) {
- assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
- }
- const assetManager = assetManagerRef.current;
-
- const extractDocumentFromResponse = useCallback(
- (payload) => {
- if (!payload) {
- return null;
- }
- const hydratedDetail = assetManager.hydrateDetail(payload);
- return hydratedDetail?.document || payload.document || payload;
- },
- [assetManager],
- );
-
- 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,
- clearSelection,
- handleEntrySelection,
- promoteSelectionOrder: promoteSelectionOrderRaw,
- configureSelectionEnvironment,
- } = selection;
-
- const getDocumentAsset = useCallback((doc, type) => {
- if (!doc || !type) return null;
- return getAssetFromVersion(doc.current_version || null, type);
- }, []);
-
- const bootstrapInitializedRef = useRef(false);
- const dragCounterRef = useRef(0);
- const detailFolderFetchRef = useRef(new Set());
-
-
- 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);
- setSearchResults(null);
- setTags([]);
- setCorrespondents([]);
- setSearchQuery('');
- setActiveTagFilters([]);
- setActiveCorrespondentFilters([]);
- setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
- setActivePreviewId(null);
- detailPanelControlRef.current.close();
- assetManager.reset();
- setPreviewEntries(() => new Map());
- previewInflightRef.current = new Map();
- dragCounterRef.current = 0;
- breadcrumbFetchRef.current = new Set();
- detailFolderFetchRef.current = new Set();
- bootstrapInitializedRef.current = false;
- selectionInitializedRef.current = false;
- tenantIdRef.current = null;
- }, [
- assetManager,
- selectionAnchorRef,
- selectionInitializedRef,
- selectionOrderRef,
- setFocusedDocumentId,
- setSelectedEntries,
- setSelectionOrder,
- ]);
-
- const tagLookupById = useMemo(() => {
- const map = new Map();
- tags.forEach((tag) => {
- if (tag?.id) {
- map.set(tag.id, tag);
- }
- });
- return map;
- }, [tags]);
-
- const correspondentLookupByName = useMemo(() => {
- const map = new Map();
- correspondents.forEach((correspondent) => {
- if (correspondent?.name) {
- map.set(correspondent.name.toLowerCase(), correspondent);
- }
- });
- return map;
- }, [correspondents]);
- useEffect(() => {
- if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
- resetWorkspaceState();
- }
- }, [appStatus, resetWorkspaceState]);
-
- useEffect(() => {
- folderContentsRef.current = folderContents;
- }, [folderContents]);
-
- useEffect(() => {
- tokenRef.current = token;
- }, [token]);
-
- useEffect(() => {
- tenantIdRef.current = currentTenantId;
- }, [currentTenantId]);
-
- useEffect(() => {
- const requestInterceptor = api.interceptors.request.use((config) => {
- const currentToken = tokenRef.current;
- if (currentToken) {
- config.headers = config.headers || {};
- if (!config.headers.Authorization) {
- config.headers.Authorization = `Bearer ${currentToken}`;
- }
- }
- return config;
- });
-
- const responseInterceptor = api.interceptors.response.use(
- (response) => response,
- async (error) => {
- const { response, config } = error;
- if (!response || !config) {
- return Promise.reject(error);
- }
-
- const status = response.status;
- const url = typeof config.url === 'string' ? config.url : '';
- const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
-
- if (status === 401 && !config._retry && !isAuthRoute) {
- console.warn('[Auth] 401 received for', url, '- attempting token refresh');
-
- if (!refreshPromiseRef.current) {
- refreshPromiseRef.current = (async () => {
- try {
- return await refreshAccessToken();
- } finally {
- refreshPromiseRef.current = null;
- }
- })();
- }
-
- try {
- const newToken = await refreshPromiseRef.current;
- if (!newToken) {
- throw new Error('No token returned from refresh');
- }
- config._retry = true;
- config.headers = config.headers || {};
- config.headers.Authorization = `Bearer ${newToken}`;
- console.log('[Auth] Retrying original request', url);
- try {
- return await api(config);
- } catch (retryError) {
- if (retryError?.response?.status === 401) {
- notifyApiError(retryError, 'Session expired. Please log in again.');
- }
- throw retryError;
- }
- } catch (refreshError) {
- console.warn('[Auth] Refresh failed, clearing session');
- notifyApiError(refreshError, 'Session expired. Please log in again.');
- return Promise.reject(refreshError);
- }
- }
-
- return Promise.reject(error);
- },
- );
-
- return () => {
- api.interceptors.request.eject(requestInterceptor);
- api.interceptors.response.eject(responseInterceptor);
- };
- }, [notifyApiError, refreshAccessToken]);
-
- useEffect(() => {
- if (!selectedDocumentIds.length) {
- return;
- }
- if (!selectedDocumentIds.includes(activePreviewId)) {
- setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
- }
- selectionInitializedRef.current = true;
- }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]);
-
- const currentFolderName = useMemo(() => {
- if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
- return currentFolder.name;
- }, [selectedFolder, currentFolder]);
-
- const isFilterActive = useMemo(
- () =>
- searchQuery.trim().length > 0 ||
- activeTagFilters.length > 0 ||
- activeCorrespondentFilters.length > 0,
- [searchQuery, activeTagFilters, activeCorrespondentFilters],
- );
-
- const applySelectedFolder = useCallback(
- (folderId, contents) => {
- const subfolders = contents?.subfolders ?? [];
- const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
- const folderInfo = contents?.folder ?? null;
-
- setCurrentSubfolders(subfolders);
- setDocuments(docs);
- setCurrentFolder(folderInfo);
-
- const availableDocKeys = docs
- .map((doc) => resolveDocumentRowKey(doc.id))
- .filter(Boolean);
- const availableDocKeySet = new Set(availableDocKeys);
- const availableFolderKeys = new Set(
- subfolders
- .map((folder) => resolveFolderRowKey(folder.id))
- .filter(Boolean),
- );
-
- let nextDocKeys = [];
- let mergedSelection = [];
-
- setSelectedEntries((previous) => {
- const previousFolderKeys = previous
- .filter(isFolderRowKey)
- .filter((key) => availableFolderKeys.has(key));
- const previousDocKeys = previous.filter(isDocumentRowKey);
-
- if (selectionInitializedRef.current) {
- nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
- } else {
- nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
- }
-
- mergedSelection = [...previousFolderKeys, ...nextDocKeys];
- return mergedSelection;
- });
-
- const nextFocus = (() => {
- const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId);
- if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
- return focusedDocumentId;
- }
- if (nextDocKeys.length) {
- const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
- return getRowId(lastDocKey) || null;
- }
- return null;
- })();
-
- setFocusedDocumentId(nextFocus);
- selectionAnchorRef.current = nextDocKeys.length
- ? nextDocKeys[nextDocKeys.length - 1]
- : null;
- selectionOrderRef.current = mergedSelection;
- setSelectionOrder(mergedSelection);
-
- return nextFocus;
- },
- [
- assetManager,
- focusedDocumentId,
- selectionAnchorRef,
- selectionInitializedRef,
- selectionOrderRef,
- setFocusedDocumentId,
- setSelectedEntries,
- setSelectionOrder,
- ],
- );
-
- const showingSearchResults = searchResults !== null;
-
- const visibleDocuments = useMemo(
- () => (showingSearchResults ? searchResults : documents),
- [showingSearchResults, searchResults, documents],
- );
-
- const visibleDocumentIds = useMemo(
- () => visibleDocuments.map((doc) => doc.id),
- [visibleDocuments],
- );
-
- 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 documentLookup = useMemo(() => {
- const map = new Map();
- const push = (items) => {
- (items || []).forEach((doc) => {
- if (doc?.id) {
- map.set(doc.id, doc);
- }
- });
- };
-
- push(documents);
- if (Array.isArray(searchResults)) {
- push(searchResults);
- }
- return map;
- }, [documents, searchResults]);
-
- const mapDocumentCaches = useCallback(
- (mapper) => {
- if (typeof mapper !== 'function') {
- return;
- }
-
- const applyToList = (list) => {
- let changed = false;
- const next = list.map((doc) => {
- const updated = mapper(doc);
- if (updated === undefined || updated === doc) {
- return doc;
- }
- changed = true;
- return updated;
- });
- return changed ? next : list;
- };
-
- setDocuments((prev) => applyToList(prev));
- setSearchResults((prev) => {
- if (!Array.isArray(prev)) {
- return prev;
- }
- return applyToList(prev);
- });
- setFolderContents((prev) => {
- if (!prev.size) {
- return prev;
- }
- let changed = false;
- const next = new Map();
- prev.forEach((contents, key) => {
- const docs = Array.isArray(contents?.documents) ? contents.documents : null;
- if (!docs || docs.length === 0) {
- next.set(key, contents);
- return;
- }
- let docsChanged = false;
- const updatedDocs = docs.map((doc) => {
- const updated = mapper(doc);
- if (updated === undefined || updated === doc) {
- return doc;
- }
- docsChanged = true;
- return updated;
- });
- if (docsChanged) {
- changed = true;
- next.set(key, { ...contents, documents: updatedDocs });
- } else {
- next.set(key, contents);
- }
- });
- return changed ? next : prev;
- });
- },
- [setDocuments, setSearchResults, setFolderContents],
- );
-
- const updateDocumentCaches = useCallback(
- (documentId, updater) => {
- if (!documentId || typeof updater !== 'function') {
- return;
- }
-
- mapDocumentCaches((doc) => {
- if (!doc || doc.id !== documentId) {
- return doc;
- }
- const updated = updater(doc);
- return updated === undefined ? doc : updated;
- });
- },
- [mapDocumentCaches],
- );
-
- const removeDocumentsFromCaches = useCallback(
- (documentIds) => {
- if (!documentIds || documentIds.length === 0) {
- return;
- }
-
- const idSet = new Set(documentIds);
-
- setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
- setSearchResults((prev) => {
- if (!Array.isArray(prev)) {
- return prev;
- }
- const filtered = prev.filter((doc) => !idSet.has(doc.id));
- return filtered.length === prev.length ? prev : filtered;
- });
-
- setFolderContents((prev) => {
- if (!prev.size) {
- return prev;
- }
- let changed = false;
- const next = new Map();
- prev.forEach((contents, key) => {
- const docs = Array.isArray(contents?.documents) ? contents.documents : null;
- if (!docs || docs.length === 0) {
- next.set(key, contents);
- return;
- }
- const filtered = docs.filter((doc) => !idSet.has(doc.id));
- if (filtered.length !== docs.length) {
- changed = true;
- next.set(key, { ...contents, documents: filtered });
- } else {
- next.set(key, contents);
- }
- });
- return changed ? next : prev;
- });
-
- setPreviewEntries((prev) => {
- if (!prev.size) {
- return prev;
- }
- let changed = false;
- const next = new Map(prev);
- idSet.forEach((id) => {
- if (next.delete(id)) {
- changed = true;
- }
- });
- return changed ? next : prev;
- });
-
- idSet.forEach((id) => {
- previewInflightRef.current.delete(id);
- });
- },
- [setDocuments, setSearchResults, setFolderContents, setPreviewEntries],
- );
-
- const folderOptions = useMemo(() => {
- const cache = new Map();
- const computePath = (id) => {
- if (cache.has(id)) {
- return cache.get(id);
- }
- if (!id || id === 'root') {
- cache.set('root', DEFAULT_FOLDER_NAME);
- return DEFAULT_FOLDER_NAME;
- }
- const node = folderNodes.get(id);
- if (!node) {
- return 'Folder';
- }
- const parentId = node.parentId || 'root';
- const parentPath = computePath(parentId);
- const name = node.name || 'Folder';
- const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
- cache.set(id, fullPath);
- return fullPath;
- };
-
- const entries = [];
- folderNodes.forEach((node, id) => {
- if (!node) return;
- entries.push({ id, label: computePath(id) });
- });
-
- entries.sort((a, b) => {
- if (a.id === 'root') return -1;
- if (b.id === 'root') return 1;
- return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
- });
-
- return entries;
- }, [folderNodes]);
-
- const folderLabelMap = useMemo(() => {
- const map = new Map();
- folderOptions.forEach((option) => {
- map.set(option.id, option.label);
- });
- return map;
- }, [folderOptions]);
-
- const navigableRows = useMemo(() => {
- const entries = [];
- if (!showingSearchResults) {
- currentSubfolders.forEach((folder) => {
- const key = resolveFolderRowKey(folder.id);
- if (key) {
- entries.push({ key, type: 'folder', id: folder.id });
- }
- });
- }
- visibleDocuments.forEach((doc) => {
- const key = resolveDocumentRowKey(doc.id);
- if (key) {
- entries.push({ key, type: 'document', id: doc.id });
- }
- });
- return entries;
- }, [showingSearchResults, currentSubfolders, visibleDocuments]);
-
- const navigableRowKeys = useMemo(
- () => navigableRows.map((entry) => entry.key),
- [navigableRows],
- );
-
- useEffect(() => {
- configureSelectionEnvironment({
- visibleRowKeySet,
- navigableRowKeys,
- });
- }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
-
- const promoteSelectionOrder = useCallback(
- (docId) => {
- if (!docId) return;
- promoteSelectionOrderRaw(docId);
- const rowKey = resolveDocumentRowKey(docId);
- if (rowKey) {
- selectionAnchorRef.current = rowKey;
- }
- setFocusedDocumentId(docId);
- setActivePreviewId(docId);
- },
- [
- promoteSelectionOrderRaw,
- selectionAnchorRef,
- setFocusedDocumentId,
- setActivePreviewId,
- ],
- );
-
- const clearDocumentSelection = useCallback(() => {
- clearSelection();
- }, [clearSelection]);
-
- const prevFocusedDocIdRef = useRef(focusedDocumentId);
- useEffect(() => {
- const previous = prevFocusedDocIdRef.current;
- if (previous === focusedDocumentId) {
- return;
- }
- prevFocusedDocIdRef.current = focusedDocumentId;
- if (focusedDocumentId) {
- setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId));
- } else {
- setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null));
- }
- }, [focusedDocumentId, setFocusedRowKey]);
-
- useEffect(() => {
- if (!navigableRowKeys.length) {
- if (focusedRowKey) {
- setFocusedRowKey(null);
- }
- return;
- }
-
- if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
- return;
- }
-
- const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
- if (docKey && navigableRowKeys.includes(docKey)) {
- setFocusedRowKey(docKey);
- return;
- }
-
- const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key));
- if (selectedKey) {
- setFocusedRowKey(selectedKey);
- return;
- }
-
- if (focusedRowKey) {
- setFocusedRowKey(null);
- }
- }, [
- focusedRowKey,
- focusedDocumentId,
- navigableRowKeys,
- selectedEntries,
- setFocusedRowKey,
- ]);
-
-
- const ensureFolderData = useCallback(
- async (
- folderId,
- {
- force = false,
- includeDocuments = true,
- prefetchDepth = 0,
- sortField: overrideSortField,
- sortDirection: overrideSortDirection,
- } = {},
- ) => {
- const requestTenantId = tenantIdRef.current;
- const cached = folderContentsRef.current.get(folderId);
- const cachedSortField = cached?.__sortField ?? DEFAULT_SORT_FIELD;
- const cachedSortDirection = cached?.__sortDirection ?? DEFAULT_SORT_DIRECTION;
- const sortField = includeDocuments
- ? overrideSortField || documentsSortFieldRef.current || DEFAULT_SORT_FIELD
- : null;
- const sortDirection = includeDocuments
- ? overrideSortDirection || documentsSortDirectionRef.current || DEFAULT_SORT_DIRECTION
- : null;
- const cachedSortMatches =
- !includeDocuments
- || !cached
- || (cachedSortField === sortField && cachedSortDirection === sortDirection);
-
- if (!force && cached) {
- const includesDocuments = Boolean(cached.__includesDocuments);
- if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
- if (prefetchDepth > 0) {
- const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
- await Promise.allSettled(
- subfolders.map((entry) =>
- ensureFolderData(entry.id, {
- includeDocuments: false,
- prefetchDepth: prefetchDepth - 1,
- force: false,
- }),
- ),
- );
- }
- return cached;
- }
- }
-
- const path = folderId === 'root' ? 'root' : folderId;
- const params = {};
- if (!includeDocuments) {
- params.include_documents = false;
- } else {
- params.sort = sortField;
- params.dir = sortDirection;
- }
- const requestConfig = Object.keys(params).length ? { params } : {};
- const { data } = await api.get(`/folders/${path}/contents`, requestConfig);
- const hydrated = assetManager.hydrateFolderContents(data);
- const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
- const childIds = childFolders.map((child) => child.id);
-
- const enriched = {
- ...hydrated,
- __includesDocuments: includeDocuments,
- __sortField: includeDocuments ? sortField : cachedSortField,
- __sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
- };
-
- if (tenantIdRef.current !== requestTenantId) {
- return enriched;
- }
-
- setFolderNodes((prev) => {
- const next = new Map(prev);
- const existingNode = next.get(folderId) || {
- id: folderId,
- name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
- parentId: data.folder?.parent_id || 'root',
- children: [],
- expanded: folderId === 'root',
- loaded: false,
- hasChildren: false,
- };
-
- next.set(folderId, {
- ...existingNode,
- name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
- parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
- children: childIds,
- expanded: folderId === 'root' ? true : existingNode.expanded,
- loaded: true,
- hasChildren: childIds.length > 0,
- });
-
- childFolders.forEach((child) => {
- const childNode = next.get(child.id);
- const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
- const childHasChildren = (() => {
- if (childNode?.loaded) {
- return previousChildren.length > 0;
- }
- if (Array.isArray(child?.subfolders)) {
- return child.subfolders.length > 0;
- }
- if (typeof child?.has_children === 'boolean') {
- return child.has_children;
- }
- if (typeof child?.hasChildren === 'boolean') {
- return child.hasChildren;
- }
- if (typeof childNode?.hasChildren === 'boolean') {
- return childNode.hasChildren;
- }
- return false;
- })();
- next.set(child.id, {
- id: child.id,
- name: child.name,
- parentId: child.parent_id ?? 'root',
- children: previousChildren,
- expanded: childNode?.expanded ?? false,
- loaded: childNode?.loaded ?? false,
- hasChildren: childHasChildren,
- });
- });
-
- return next;
- });
-
- if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
- await Promise.allSettled(
- childIds.map((childId) =>
- ensureFolderData(childId, {
- includeDocuments: false,
- force: false,
- prefetchDepth: prefetchDepth - 1,
- }),
- ),
- );
- }
-
- if (includeDocuments) {
- setFolderContents((prev) => {
- if (tenantIdRef.current !== requestTenantId) {
- return prev;
- }
- const next = new Map(prev);
- next.set(folderId, enriched);
- return next;
- });
- } else {
- setFolderContents((prev) => {
- if (tenantIdRef.current !== requestTenantId) {
- return prev;
- }
- const next = new Map(prev);
- const existingEntry = next.get(folderId);
- if (existingEntry) {
- next.set(folderId, {
- ...existingEntry,
- ...hydrated,
- documents: existingEntry.__includesDocuments
- ? existingEntry.documents
- : hydrated.documents,
- __includesDocuments: existingEntry.__includesDocuments || false,
- __sortField: existingEntry.__sortField ?? enriched.__sortField,
- __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
- });
- } else {
- next.set(folderId, enriched);
- }
- return next;
- });
- }
-
- return enriched;
- },
- [assetManager, documentsSortDirectionRef, documentsSortFieldRef],
- );
-
- const isInvalidFolderDrop = useCallback(
- (sourceId, targetId) => {
- if (!sourceId) return false;
- if (!targetId || targetId === 'root') {
- return false;
- }
- if (sourceId === targetId) {
- return true;
- }
-
- let current = targetId;
- const visited = new Set();
- while (current && current !== 'root' && !visited.has(current)) {
- visited.add(current);
- if (current === sourceId) {
- return true;
- }
- const node = folderNodes.get(current);
- if (!node) break;
- current = node.parentId ?? 'root';
- }
- return false;
- },
- [folderNodes],
- );
-
- const moveFolder = useCallback(
- async (folderId, targetFolderId) => {
- const node = folderNodes.get(folderId);
- if (!node) {
- setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
- return;
- }
-
- const previousParentKey = node.parentId ?? 'root';
- const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root';
-
- if (previousParentKey === targetKey) {
- return;
- }
-
- const parent_id = targetKey === 'root' ? null : targetKey;
-
- try {
- await api.patch(`/folders/${folderId}`, { parent_id });
-
- setFolderNodes((prev) => {
- const next = new Map(prev);
- const currentNode = next.get(folderId);
- if (!currentNode) {
- return prev;
- }
-
- const updatedNode = { ...currentNode, parentId: parent_id ?? null };
- next.set(folderId, updatedNode);
-
- const previousParent = next.get(previousParentKey);
- if (previousParent) {
- const remainingChildren = (previousParent.children || []).filter(
- (childId) => childId !== folderId,
- );
- next.set(previousParentKey, {
- ...previousParent,
- children: remainingChildren,
- hasChildren: remainingChildren.length > 0,
- });
- }
-
- if (!next.has(targetKey)) {
- next.set(targetKey, {
- id: targetKey,
- name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder',
- parentId: targetKey === 'root' ? null : null,
- children: [],
- expanded: targetKey === 'root',
- loaded: false,
- hasChildren: false,
- });
- }
-
- const targetNode = next.get(targetKey);
- if (targetNode && !targetNode.children.includes(folderId)) {
- next.set(targetKey, {
- ...targetNode,
- children: [...targetNode.children, folderId],
- hasChildren: true,
- });
- }
-
- return next;
- });
-
- const refreshTargets = new Set([previousParentKey, targetKey]);
- for (const key of refreshTargets) {
- if (key === 'root') {
- await ensureFolderData('root', { force: true, prefetchDepth: 1 });
- } else {
- await ensureFolderData(key, { force: true, prefetchDepth: 1 });
- }
- }
-
- if (selectedFolder === folderId) {
- await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
- setSelectedFolder(folderId);
- }
-
- setStatusMessage('Folder moved.', 'success');
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to move folder.';
- notifyApiError(error, message);
-
- const refreshTargets = new Set([previousParentKey, targetKey]);
- for (const key of refreshTargets) {
- if (key === 'root') {
- await ensureFolderData('root', { force: true, prefetchDepth: 1 });
- } else {
- await ensureFolderData(key, { force: true, prefetchDepth: 1 });
- }
- }
- }
- },
- [
- folderNodes,
- ensureFolderData,
- selectedFolder,
- setSelectedFolder,
- setFolderNodes,
- notifyApiError,
- setStatusMessage,
- ],
- );
-
- const refreshTags = useCallback(async () => {
- const requestTenantId = tenantIdRef.current;
- try {
- const { data } = await api.get('/tags');
- if (tenantIdRef.current !== requestTenantId) {
- return;
- }
- setTags(data || []);
- } catch (error) {
- if (tenantIdRef.current !== requestTenantId) {
- return;
- }
- notifyApiError(error, 'Unable to load tags.');
- }
- }, [notifyApiError]);
-
- const refreshCorrespondents = useCallback(async () => {
- const requestTenantId = tenantIdRef.current;
- try {
- const { data } = await api.get('/correspondents');
- if (tenantIdRef.current !== requestTenantId) {
- return;
- }
- setCorrespondents(data || []);
- } catch (error) {
- if (tenantIdRef.current !== requestTenantId) {
- return;
- }
- notifyApiError(error, 'Unable to load correspondents.');
- }
- }, [notifyApiError]);
-
- const {
- passkeys,
- passkeysSupported,
- passkeysLoading,
- registeringPasskey,
- revokingPasskeyId,
- refreshPasskeys,
- registerPasskey,
- revokePasskey,
- } = usePasskeys({
- api,
- notifyApiError,
- setStatusMessage,
- token,
- });
-
- const handleTagUpdate = useCallback(
- async (tagId, changes) => {
- if (!tagId) {
- throw new Error('Missing tag identifier.');
- }
-
- const payload = {};
- if (typeof changes.label === 'string') {
- payload.label = changes.label;
- }
- if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
- payload.color = changes.color;
- }
-
- if (Object.keys(payload).length === 0) {
- return false;
- }
-
- try {
- await api.patch(`/tags/${tagId}`, payload);
- await refreshTags();
- setStatusMessage('Tag updated.', 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to update tag.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [refreshTags, notifyApiError, setStatusMessage],
- );
-
- const handleTagCreate = useCallback(
- async ({ label, color } = {}) => {
- const payload = tagManager.buildPayload({ label, color });
- try {
- await api.post('/tags', payload);
- await refreshTags();
- setStatusMessage('Tag created.', 'success');
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to create tag.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [refreshTags, notifyApiError, setStatusMessage, tagManager],
- );
-
- const handleCorrespondentUpdate = useCallback(
- async (correspondentId, changes) => {
- if (!correspondentId) {
- throw new Error('Missing correspondent identifier.');
- }
-
- const payload = {};
- if (typeof changes.name === 'string') {
- const trimmed = changes.name.trim();
- if (!trimmed) {
- throw new Error('Correspondent name cannot be empty.');
- }
- payload.name = trimmed;
- }
-
- if (Object.keys(payload).length === 0) {
- return false;
- }
-
- try {
- await api.patch(`/correspondents/${correspondentId}`, payload);
- await refreshCorrespondents();
- setStatusMessage('Correspondent updated.', 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to update correspondent.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [refreshCorrespondents, notifyApiError, setStatusMessage],
- );
-
- const handleCorrespondentCreate = useCallback(
- async ({ name }) => {
- const trimmed = typeof name === 'string' ? name.trim() : '';
- if (!trimmed) {
- throw new Error('Correspondent name is required.');
- }
- try {
- const { data } = await api.post('/correspondents', { name: trimmed });
- await refreshCorrespondents();
- setStatusMessage('Correspondent created.', 'success');
- return data;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to create correspondent.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [refreshCorrespondents, notifyApiError, setStatusMessage],
- );
-
- const handleCorrespondentDelete = useCallback(
- async (correspondentId) => {
- if (!correspondentId) {
- throw new Error('Missing correspondent identifier.');
- }
-
- const stripFromDoc = (doc) => {
- if (!doc || !Array.isArray(doc.correspondents)) {
- return doc;
- }
- const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
- if (next.length === doc.correspondents.length) {
- return doc;
- }
- return { ...doc, correspondents: next };
- };
-
- try {
- await api.delete(`/correspondents/${correspondentId}`);
- await refreshCorrespondents();
-
- mapDocumentCaches(stripFromDoc);
-
- setStatusMessage('Correspondent deleted.', 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to delete correspondent.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches],
- );
-
- 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]);
-
- useEffect(() => {
- if (!sortRefreshReadyRef.current) {
- sortRefreshReadyRef.current = true;
- return;
- }
- if (!isFilterActive && token) {
- refreshCurrentFolder();
- }
- }, [
- documentsSortField,
- documentsSortDirection,
- isFilterActive,
- refreshCurrentFolder,
- token,
- ]);
-
- const handleDocumentCorrespondentAttach = useCallback(
- async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
- if (!documentId || !correspondentId) {
- throw new Error('Missing document or correspondent.');
- }
- try {
- await api.post(`/documents/${documentId}/correspondents`, {
- assignments: [{ correspondent_id: correspondentId }],
- replace: false,
- });
- if (refresh) {
- await refreshCurrentFolder();
- }
- if (notify) {
- setStatusMessage('Correspondent assigned.', 'success');
- }
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to assign correspondent.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [notifyApiError, refreshCurrentFolder, setStatusMessage],
- );
-
- const handleCorrespondentRemove = useCallback(
- async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
- if (!documentId || !correspondentId) {
- throw new Error('Missing document or correspondent.');
- }
- try {
- await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
- if (refresh) {
- await refreshCurrentFolder();
- }
- if (notify) {
- setStatusMessage('Correspondent removed.', 'success');
- }
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to remove correspondent.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [notifyApiError, refreshCurrentFolder, setStatusMessage],
- );
-
- const handleCorrespondentAdd = useCallback(
- async ({ document, name, input = null, option = null }) => {
- if (!document?.id) {
- throw new Error('Missing document for correspondent assignment.');
- }
- const trimmed = typeof name === 'string' ? name.trim() : '';
- if (!trimmed) {
- setStatusMessage('Correspondent name is required.', 'error');
- return;
- }
-
- let target = null;
- if (option && option.id) {
- target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
- } else {
- target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
- }
- if (!target) {
- try {
- target = await handleCorrespondentCreate({ name: trimmed });
- } catch (error) {
- return;
- }
- }
-
- if (!target?.id) {
- setStatusMessage('Unable to resolve correspondent.', 'error');
- return;
- }
-
- try {
- await handleDocumentCorrespondentAttach({
- documentId: document.id,
- correspondentId: target.id,
- });
- if (input) {
- input.value = '';
- }
- } catch (error) {
- setStatusMessage('Failed to assign correspondent.', 'error');
- console.error('[documents] assign correspondent failed', error);
- }
- },
- [
- handleCorrespondentCreate,
- handleDocumentCorrespondentAttach,
- correspondentLookupByName,
- setStatusMessage,
- ],
- );
-
- 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 handleTagDelete = useCallback(
- async (tagId) => {
- if (!tagId) {
- throw new Error('Missing tag identifier.');
- }
-
- try {
- await api.delete(`/tags/${tagId}`);
- setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
-
- const stripTagFromDoc = (doc) => {
- if (!doc || !Array.isArray(doc.tags)) {
- return doc;
- }
- const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
- if (nextTags.length === doc.tags.length) {
- return doc;
- }
- return { ...doc, tags: nextTags };
- };
-
- mapDocumentCaches(stripTagFromDoc);
-
- await refreshTags();
- setStatusMessage('Tag deleted.', 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to delete tag.';
- notifyApiError(error, message);
- throw new Error(message);
- }
- },
- [
- refreshTags,
- notifyApiError,
- setStatusMessage,
- mapDocumentCaches,
- setActiveTagFilters,
- ],
- );
-
- const expandFolderAncestors = useCallback(
- (targetId) => {
- if (!targetId || targetId === 'root') {
- setFolderNodes((prev) => {
- if (prev.get('root')?.expanded) {
- return prev;
- }
- const next = new Map(prev);
- const rootNode = next.get('root');
- if (rootNode) {
- next.set('root', { ...rootNode, expanded: true });
- }
- return next;
- });
- return;
- }
-
- setFolderNodes((prev) => {
- const next = new Map(prev);
- let currentId = targetId;
- let guard = 0;
- while (currentId && !next.has(currentId) && guard < 32) {
- guard += 1;
- const node = prev.get(currentId);
- if (!node) {
- break;
- }
- currentId = node.parentId ?? 'root';
- }
-
- currentId = targetId;
- guard = 0;
- while (currentId && guard < 32) {
- guard += 1;
- const node = next.get(currentId);
- if (!node) {
- break;
- }
- if (!node.expanded && currentId !== targetId) {
- next.set(currentId, { ...node, expanded: true });
- }
- currentId = node.parentId ?? 'root';
- if (!currentId || currentId === 'root') {
- const rootNode = next.get('root');
- if (rootNode && !rootNode.expanded) {
- next.set('root', { ...rootNode, expanded: true });
- }
- break;
- }
- }
- return next;
- });
- },
- []);
-
- const ensureFolderAncestorsLoaded = useCallback(
- async (targetId) => {
- if (!targetId || targetId === 'root') {
- return;
- }
-
- const fetchAncestor = async (folderId, guard = 0) => {
- if (!folderId || folderId === 'root' || guard > 32) {
- return;
- }
-
- const existing = folderNodes.get(folderId);
- if (existing?.loaded) {
- return;
- }
-
- try {
- const contents = await ensureFolderData(folderId, {
- includeDocuments: false,
- force: false,
- prefetchDepth: 0,
- });
- const parentId = contents?.folder?.parent_id ?? 'root';
- if (parentId && parentId !== 'root') {
- await fetchAncestor(parentId, guard + 1);
- }
- } catch (error) {
- console.warn('Failed to ensure ancestor folder for navigation', folderId, error);
- }
- };
-
- await fetchAncestor(targetId, 0);
- },
- [folderNodes, ensureFolderData],
- );
-
- const loadFolder = useCallback(
- async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
- const targetId = folderId || 'root';
- setSelectedFolder(targetId);
- await ensureFolderAncestorsLoaded(targetId);
- expandFolderAncestors(targetId);
- if (showLoading) setLoading(true);
- try {
- const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
- if (targetId !== 'root') {
- try {
- await ensureFolderData('root', {
- force: false,
- includeDocuments: false,
- prefetchDepth: 1,
- });
- } catch (error) {
- console.warn('Failed to refresh root folder tree', error);
- }
- }
- applySelectedFolder(targetId, contents);
- if (!preserveSearch) {
- setSearchResults(null);
- }
- } catch (error) {
- notifyApiError(error, 'Failed to load folder contents.');
- } finally {
- if (showLoading) setLoading(false);
- }
- },
- [
- ensureFolderData,
- applySelectedFolder,
- notifyApiError,
- ensureFolderAncestorsLoaded,
- expandFolderAncestors,
- ],
- );
-
- const selectFolder = useCallback(
- async (folderId, { replace = false, immediate = false } = {}) => {
- const targetId = folderId && folderId !== 'root' ? folderId : 'root';
-
- await ensureFolderAncestorsLoaded(targetId);
- expandFolderAncestors(targetId);
-
- if (!navigate || immediate) {
- await loadFolder(targetId, { preserveSearch: isFilterActive });
- setSelectedFolder(targetId);
- return;
- }
-
- const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
- navigate(path, { replace });
- },
- [
- ensureFolderAncestorsLoaded,
- expandFolderAncestors,
- navigate,
- loadFolder,
- isFilterActive,
- setSelectedFolder,
- ],
- );
-
- 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,
- ]);
-
- const handleBulkCorrespondentAdd = useCallback(
- async ({ name, input, documentIds }) => {
- const trimmed = typeof name === 'string' ? name.trim() : '';
- if (!trimmed) {
- setStatusMessage('Correspondent name is required.', 'error');
- return;
- }
- const targets = resolveTargetDocumentIds(documentIds);
- if (!targets.length) {
- setStatusMessage('Select documents before assigning correspondents.', 'error');
- return;
- }
-
- let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
- if (!target) {
- try {
- target = await handleCorrespondentCreate({ name: trimmed });
- } catch (error) {
- return;
- }
- }
-
- if (!target?.id) {
- setStatusMessage('Unable to resolve correspondent.', 'error');
- return;
- }
-
- const response = await api.post('/documents/bulk/correspondents', {
- document_ids: targets,
- assignments: [
- {
- correspondent_id: target.id,
- },
- ],
- action: 'add',
- });
-
- const { assigned = 0, removed = 0 } = response.data || {};
-
- await refreshCurrentFolder();
- const assignedSuffix = assigned === 1 ? '' : 's';
- if (removed > 0) {
- const removedSuffix = removed === 1 ? '' : 's';
- setStatusMessage(
- `Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`,
- 'success',
- );
- } else {
- setStatusMessage(
- `Correspondent assigned to ${assigned} document${assignedSuffix}.`,
- 'success',
- );
- }
-
- if (input) {
- input.value = '';
- }
- },
- [
- correspondentLookupByName,
- handleCorrespondentCreate,
- refreshCurrentFolder,
- resolveTargetDocumentIds,
- setStatusMessage,
- ],
- );
-
- const handleBulkCorrespondentRemove = useCallback(
- async ({ assignments = [], documentIds }) => {
- if (!assignments.length) {
- setStatusMessage('Select a correspondent to remove.', 'error');
- return;
- }
-
- const targets = resolveTargetDocumentIds(documentIds);
-
- if (!targets.length) {
- setStatusMessage('Select documents before removing correspondents.', 'error');
- return;
- }
-
- const normalizedAssignments = assignments.map((entry) => ({
- correspondent_id: entry.correspondent_id,
- }));
-
- const response = await api.post('/documents/bulk/correspondents', {
- document_ids: targets,
- assignments: normalizedAssignments,
- action: 'remove',
- });
-
- const { assigned = 0, removed = 0 } = response.data || {};
- await refreshCurrentFolder();
-
- if (removed > 0) {
- const removedSuffix = removed === 1 ? '' : 's';
- setStatusMessage(
- `Correspondent removed from ${removed} link${removedSuffix}.`,
- 'success',
- );
- } else if (assigned > 0) {
- const assignedSuffix = assigned === 1 ? '' : 's';
- setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
- } else {
- setStatusMessage('No correspondents changed.', 'info');
- }
- },
- [refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
- );
-
- 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 bulkTagOperation = useCallback(
- async ({ labels, action, documentIds }) => {
- const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
- if (!normalized.length) {
- return { ok: false, reason: 'no-labels' };
- }
- const targetDocumentIds = resolveTargetDocumentIds(documentIds);
- if (!targetDocumentIds.length) {
- return { ok: false, reason: 'no-selection' };
- }
-
- let tagIds = [];
-
- if (action === 'remove') {
- const missing = normalized.find(
- (label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
- );
- if (missing) {
- return { ok: false, reason: 'tag-missing', label: missing };
- }
-
- tagIds = normalized.map((label) => {
- const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
- return tag?.id;
- }).filter(Boolean);
- }
-
- setLoading(true);
- try {
- if (action === 'add') {
- const createdIds = [];
- for (const label of normalized) {
- let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
- if (!tag) {
- const payload = tagManager.buildPayload({ label });
- const { data } = await api.post('/tags', payload);
- tag = data;
- await refreshTags();
- }
- createdIds.push(tag.id);
- }
- tagIds = Array.from(new Set(createdIds));
- }
-
- tagIds = Array.from(new Set(tagIds));
-
- if (!tagIds.length) {
- return { ok: false, reason: 'no-tags' };
- }
-
- await api.post('/documents/bulk/tags', {
- document_ids: targetDocumentIds,
- tag_ids: tagIds,
- action,
- });
-
- await refreshCurrentFolder();
-
- return {
- ok: true,
- tagCount: tagIds.length,
- docsCount: targetDocumentIds.length,
- };
- } catch (error) {
- const message =
- error.response?.data?.error ||
- (action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
- notifyApiError(error, message);
- return { ok: false, reason: 'request-failed' };
- } finally {
- setLoading(false);
- }
- },
- [
- resolveTargetDocumentIds,
- tags,
- refreshTags,
- refreshCurrentFolder,
- notifyApiError,
- setLoading,
- tagManager,
- ],
- );
-
- const handleBulkTagAddFromDetail = useCallback(
- async ({ label, input, documentIds }) => {
- const trimmed = typeof label === 'string' ? label.trim() : '';
- if (!trimmed) {
- setStatusMessage('Enter a tag label.', 'error');
- return;
- }
- const targetIds = resolveTargetDocumentIds(documentIds);
- if (!targetIds.length) {
- setStatusMessage('Select documents before assigning tags.', 'error');
- return;
- }
- const result = await bulkTagOperation({
- labels: [trimmed],
- action: 'add',
- documentIds: targetIds,
- });
- if (result?.ok) {
- const { tagCount, docsCount } = result;
- setStatusMessage(
- `Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
- docsCount === 1 ? '' : 's'
- }.`,
- 'success',
- );
- if (input) {
- input.value = '';
- }
- }
- },
- [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
- );
-
- const handleBulkTagRemoveFromDetail = useCallback(
- async ({ label, input, documentIds }) => {
- const trimmed = typeof label === 'string' ? label.trim() : '';
- if (!trimmed) {
- setStatusMessage('Enter a tag label to remove.', 'error');
- return;
- }
- const targetIds = resolveTargetDocumentIds(documentIds);
- if (!targetIds.length) {
- setStatusMessage('Select documents before removing tags.', 'error');
- return;
- }
- const result = await bulkTagOperation({
- labels: [trimmed],
- action: 'remove',
- documentIds: targetIds,
- });
- if (result?.ok) {
- const { docsCount } = result;
- setStatusMessage(
- `Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
- 'success',
- );
- if (input) {
- input.value = '';
- }
- } else if (result?.reason === 'tag-missing') {
- setStatusMessage(`Tag “${result.label}” not found.`, 'error');
- }
- },
- [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
- );
- const handleBulkSelectionReanalyze = useCallback(
- async (documentIdsOverride = null) => {
- const targetIds = resolveTargetDocumentIds(documentIdsOverride);
- if (!targetIds.length) {
- setStatusMessage('Select documents before requesting re-analysis.', 'error');
- return;
- }
-
- setLoading(true);
- try {
- const { data } = await api.post('/documents/bulk/reanalyze', {
- document_ids: targetIds,
- force: true,
- });
- const queued = data?.queued ?? targetIds.length;
- setStatusMessage(
- `Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
- 'success',
- );
- } catch (error) {
- const message =
- error.response?.data?.error || 'Failed to queue document re-analysis.';
- notifyApiError(error, message);
- } finally {
- setLoading(false);
- }
- },
- [resolveTargetDocumentIds, notifyApiError, setStatusMessage],
- );
-
- const uploadFile = useCallback(
- async (file, targetFolderId) => {
- if (!file || file.size === 0) {
- setStatusMessage('Skipped empty file.', 'error');
- return null;
- }
-
- const formData = new FormData();
- formData.append('file', file, file.name);
- if (targetFolderId && targetFolderId !== 'root') {
- formData.append('folder_id', targetFolderId);
- }
-
- try {
- const { data, status } = await api.post('/documents', formData);
- const duplicate = data?.reused || status === 200;
- setStatusMessage(
- duplicate
- ? `${file.name} already exists; reused existing document.`
- : `Uploaded ${file.name}`,
- duplicate ? 'info' : 'success',
- );
- return data;
- } catch (error) {
- const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
- notifyApiError(error, message);
- throw error;
- }
- },
- [notifyApiError, setStatusMessage],
- );
-
- const folderPathCacheRef = useRef(new Map());
-
- const ensureFolderPathOnServer = useCallback(
- async (baseFolderId, segments) => {
- const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
- if (trimmedSegments.length === 0) {
- return baseFolderId ?? null;
- }
-
- const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
- const cache = folderPathCacheRef.current;
- if (cache.has(cacheKey)) {
- return cache.get(cacheKey);
- }
-
- const payload = {
- parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
- segments: trimmedSegments,
- };
-
- const { data } = await api.post('/folders/path', payload);
- cache.set(cacheKey, data.folder.id);
- return data.folder.id;
- },
- [],
- );
-
- const ensureAssetUrl = useCallback(
- async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
- if (!documentId || !asset?.id) {
- return null;
- }
-
- try {
- const entry = await assetManager.ensureAsset(documentId, asset, {
- force,
- start,
- limit,
- });
-
- if (!entry) {
- return null;
- }
-
- setDocuments((prev) =>
- prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)),
- );
-
- setSearchResults((prev) =>
- Array.isArray(prev)
- ? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc))
- : prev,
- );
-
- return entry;
- } catch (error) {
- notifyApiError(error, 'Unable to refresh document asset.');
- throw error;
- }
- },
- [assetManager, setDocuments, setSearchResults, notifyApiError],
- );
-
- const dragPreviewRef = useRef(null);
-
- const destroyDragPreview = useCallback(() => {
- const node = dragPreviewRef.current;
- if (node && node.parentNode) {
- node.parentNode.removeChild(node);
- }
- dragPreviewRef.current = null;
- }, []);
-
- useEffect(() => destroyDragPreview, [destroyDragPreview]);
-
- const createDragPreview = useCallback(
- ({ documents = [], folders = [] } = {}) => {
- destroyDragPreview();
-
- const docEntries = (documents || []).filter(Boolean);
- const folderEntries = (folders || []).filter(Boolean);
- const totalCount = docEntries.length + folderEntries.length;
- if (!totalCount) {
- return null;
- }
-
- const maxVisible = 4;
- const size = 64;
- const canvasSize = Math.round(size * 1.6);
-
- const visibleItems = [];
- docEntries.slice(0, maxVisible).forEach((doc) => {
- visibleItems.push({ type: 'document', payload: doc });
- });
-
- if (visibleItems.length < maxVisible) {
- folderEntries
- .slice(0, maxVisible - visibleItems.length)
- .forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId }));
- }
-
- const wrapper = document.createElement('div');
- wrapper.className = 'document-drag-preview';
- wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`);
-
- visibleItems.forEach((item, index) => {
- const layer = document.createElement('div');
- layer.className = 'document-drag-preview__thumb';
- const rotationMagnitude = Math.random() * 8 + 2; // 2..10 degrees
- const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude;
- layer.style.setProperty('--rotation-deg', `${rotation}deg`);
-
- if (item.type === 'document') {
- const doc = item.payload;
- const rowEl = doc?.id
- ? document.getElementById(`document-row-${doc.id}`) ||
- document.getElementById(`document-card-${doc.id}`)
- : null;
- const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
- const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
- const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
- const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
- const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
-
- let thumbWidth = size;
- let thumbHeight = size;
- if (Number.isFinite(aspectRatio) && aspectRatio > 0) {
- if (aspectRatio >= 1) {
- thumbWidth = size;
- thumbHeight = Math.max(size / aspectRatio, size * 0.5);
- } else {
- thumbHeight = size;
- thumbWidth = Math.max(size * aspectRatio, size * 0.5);
- }
- }
- layer.style.width = `${Math.round(thumbWidth)}px`;
- layer.style.height = `${Math.round(thumbHeight)}px`;
-
- const thumbSrc = thumbnailEl?.currentSrc || thumbnailEl?.src || null;
-
- if (thumbSrc) {
- layer.classList.add('document-drag-preview__thumb--image');
- layer.style.backgroundImage = `url("${thumbSrc}")`;
- } else if (placeholderEl instanceof HTMLElement) {
- const content = placeholderEl.cloneNode(true);
- content.style.pointerEvents = 'none';
- layer.appendChild(content);
- } else {
- layer.textContent = 'DOC';
- }
- } else {
- const folderId = item.payload;
- const rowEl = folderId ? document.getElementById(`folder-row-${folderId}`) : null;
- const iconEl = rowEl?.querySelector('.thumb-icon');
-
- let content = null;
- if (iconEl instanceof HTMLElement) {
- content = iconEl.cloneNode(true);
- content.classList.add('document-drag-preview__folder-thumb');
- const svg = content.querySelector('svg');
- if (svg) {
- svg.setAttribute('width', '48');
- svg.setAttribute('height', '48');
- }
- }
-
- if (!content) {
- content = document.createElement('div');
- content.className = 'document-drag-preview__folder-placeholder';
- content.textContent = 'Folder';
- }
-
- layer.appendChild(content);
- }
-
- wrapper.appendChild(layer);
- });
-
- if (totalCount > 1) {
- const badge = document.createElement('div');
- badge.className = 'document-drag-preview__count';
- badge.textContent = `${totalCount}`;
- wrapper.appendChild(badge);
- }
-
- document.body.appendChild(wrapper);
- dragPreviewRef.current = wrapper;
- return wrapper;
- },
- [destroyDragPreview],
- );
-
- const handleDocumentDragStart = useCallback(
- (event, documentOrId) => {
- const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
- if (!documentId) {
- return;
- }
-
- const documentKey = resolveDocumentRowKey(documentId);
- if (!documentKey) {
- return;
- }
-
- const isGridView = documentsViewMode === 'grid';
- const isAlreadySelected = selectedDocumentIds.includes(documentId);
- const selection = isAlreadySelected
- ? [...selectedDocumentIds]
- : isGridView
- ? [...selectedDocumentIds, documentId]
- : [documentId];
- const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
-
- if (!isAlreadySelected && !isGridView) {
- applySelection([documentKey], {
- anchor: documentKey,
- interactedKeys: [documentKey],
- });
- }
-
- const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
- const previewNode = createDragPreview({
- documents: previewDocs,
- folders: folderSelection,
- });
-
- setDraggedDocumentIds(selection);
- if (folderSelection.length) {
- setDraggedFolderId(folderSelection[0] || null);
- }
- event.dataTransfer.effectAllowed = 'move';
- try {
- event.dataTransfer.setData(
- 'application/x-papercrate-doc-list',
- JSON.stringify(selection),
- );
- if (folderSelection.length) {
- event.dataTransfer.setData(
- 'application/x-papercrate-folder-list',
- JSON.stringify(folderSelection),
- );
- if (folderSelection.length === 1) {
- event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]);
- }
- }
- } catch (error) {
- console.warn('[documents] Failed to populate drag payload', error);
- }
- if (previewNode) {
- const width = previewNode.offsetWidth || 96;
- const height = previewNode.offsetHeight || 96;
- event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
- }
- event.currentTarget.classList.add('dragging');
- },
- [
- selectedDocumentIds,
- selectedFolderIds,
- applySelection,
- documentLookup,
- createDragPreview,
- setDraggedFolderId,
- documentsViewMode,
- ],
- );
-
- const handleDocumentDragEnd = useCallback(
- (event) => {
- setDraggedDocumentIds([]);
- event.currentTarget.classList.remove('dragging');
- destroyDragPreview();
- setDraggedFolderId(null);
- },
- [destroyDragPreview, setDraggedFolderId],
- );
-
- const handleFolderDragStart = useCallback(
- (event, folderId) => {
- if (folderId === 'root') {
- return;
- }
- event.stopPropagation();
- const folderKey = resolveFolderRowKey(folderId);
- const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
-
- let effectiveFolderSelection = selectedFolderIds;
- let effectiveDocumentSelection = selectedDocumentIds;
-
- if (!isAlreadySelected && folderKey) {
- effectiveFolderSelection = [folderId];
- effectiveDocumentSelection = [];
- handleEntrySelection(folderKey, { preventDefault: () => {} });
- }
-
- const uniqueFolders = effectiveFolderSelection.length
- ? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
- : [folderId];
-
- setDraggedFolderId(folderId);
- if (effectiveDocumentSelection.length) {
- setDraggedDocumentIds(effectiveDocumentSelection);
- }
-
- event.dataTransfer.effectAllowed = 'move';
- try {
- event.dataTransfer.setData(
- 'application/x-papercrate-folder-list',
- JSON.stringify(uniqueFolders),
- );
- if (uniqueFolders.length === 1) {
- event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]);
- }
- if (effectiveDocumentSelection.length) {
- event.dataTransfer.setData(
- 'application/x-papercrate-doc-list',
- JSON.stringify(effectiveDocumentSelection),
- );
- }
- } catch (error) {
- console.warn('[folders] Failed to set drag payload', error);
- }
-
- const previewDocs = effectiveDocumentSelection
- .map((id) => documentLookup.get(id) || null)
- .filter(Boolean);
- const previewNode = createDragPreview({
- documents: previewDocs,
- folders: uniqueFolders,
- });
- if (previewNode) {
- const width = previewNode.offsetWidth || 96;
- const height = previewNode.offsetHeight || 96;
- event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
- }
- event.currentTarget?.classList.add('dragging');
- },
- [
- selectedEntries,
- selectedFolderIds,
- selectedDocumentIds,
- setDraggedFolderId,
- setDraggedDocumentIds,
- handleEntrySelection,
- documentLookup,
- createDragPreview,
- ],
- );
-
- const handleFolderDragEnd = useCallback(
- (event) => {
- if (event?.currentTarget) {
- event.currentTarget.classList.remove('dragging');
- }
- setDraggedFolderId(null);
- setDraggedDocumentIds([]);
- destroyDragPreview();
- },
- [setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
- );
-
- const ensurePreviewUrl = useCallback(
- async (documentId, { force = false } = {}) => {
- if (!documentId) return null;
-
- const existing = previewEntries.get(documentId) || null;
- const now = Date.now();
- const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
- if (!force && existing && (!expiresAt || expiresAt > now)) {
- return existing;
- }
-
- if (!force && previewInflightRef.current.has(documentId)) {
- return previewInflightRef.current.get(documentId);
- }
-
- const request = (async () => {
- try {
- const docResponse = await api.get(`/documents/${documentId}`);
- const downloadPath = docResponse.data?.document?.current_version?.download_path;
- if (!downloadPath || !resolveApiPath) {
- throw new Error('Document missing download path');
- }
-
- const href = resolveApiPath(downloadPath);
- const entry = {
- url: href,
- contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
- filename: docResponse.data?.document?.filename,
- expiresAt: Date.now() + 5 * 60 * 1000,
- };
- setPreviewEntries((prev) => {
- const next = new Map(prev);
- next.set(documentId, entry);
- return next;
- });
- return entry;
- } catch (error) {
- notifyApiError(error, 'Unable to fetch document preview.');
- throw error;
- } finally {
- previewInflightRef.current.delete(documentId);
- }
- })();
-
- previewInflightRef.current.set(documentId, request);
- return request;
- },
- [previewEntries, notifyApiError],
- );
-
- const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
- if (!dataTransfer) {
- throw new Error('No drop payload found.');
- }
-
- const items = Array.from(dataTransfer.items || []);
- console.info('[Uploads] drop start', { items: items.length, files: (dataTransfer.files || []).length });
-
- const results = [];
- const seenKeys = new Set();
-
- const pushFile = (file, ancestors = []) => {
- if (!file) return;
- const segments = (ancestors || []).filter(Boolean);
- const key = `${segments.join('/')}/${file.name}:${file.size}`;
- if (seenKeys.has(key)) {
- // skipped duplicate
- return;
- }
- seenKeys.add(key);
- results.push({ file, segments });
- // queued file
- };
-
- const readAllEntries = async (reader) => {
- const entries = [];
- let batch = [];
- do {
- // eslint-disable-next-line no-await-in-loop
- batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
- if (batch.length) {
- entries.push(...batch);
- }
- } while (batch.length);
- return entries;
- };
-
- const walkEntry = async (entry, ancestors = []) => {
- if (!entry) return;
- if (entry.isFile) {
- const file = await new Promise((resolve, reject) => {
- try {
- entry.file(resolve, reject);
- } catch (error) {
- console.warn('[Uploads] entry.file failed', error);
- reject(error);
- }
- });
- pushFile(file, ancestors);
- return;
- }
- if (entry.isDirectory) {
- const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
- const reader = entry.createReader();
- const entries = await readAllEntries(reader);
- for (const child of entries) {
- // eslint-disable-next-line no-await-in-loop
- await walkEntry(child, nextAncestors);
- }
- }
- };
-
- await Promise.all(
- items.map(async (item, index) => {
- if (item.kind !== 'file') return;
-
- const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
- if (fileFromItem) {
- const relativePath =
- typeof fileFromItem.webkitRelativePath === 'string' ? fileFromItem.webkitRelativePath : '';
- const segments = relativePath
- ? relativePath
- .split('/')
- .slice(0, -1)
- .filter(Boolean)
- : [];
- pushFile(fileFromItem, segments);
- }
-
- if (typeof item.webkitGetAsEntry === 'function') {
- try {
- const entry = item.webkitGetAsEntry();
- if (entry) {
- // processing entry
- await walkEntry(entry, []);
- return;
- }
- } catch (error) {
- console.warn('[Uploads] webkitGetAsEntry failed', error);
- }
- }
-
- if (!fileFromItem) {
- console.info('[Uploads] item missing file handle', index);
- }
- }),
- );
-
- Array.from(dataTransfer.files || []).forEach((file) => {
- if (!file) return;
- // FileList entry suppressed
- const relativePath =
- typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
- const segments = relativePath
- ? relativePath
- .split('/')
- .slice(0, -1)
- .filter(Boolean)
- : [];
- pushFile(file, segments);
- });
-
- if (!results.length) {
- throw new Error('No files detected in drop payload.');
- }
-
- console.info('[Uploads] prepared files', results.length);
-
- return results;
- }, []);
-
-
- const handleFileDrop = useCallback(
- async (dataTransfer, targetFolderId) => {
- if (!token) {
- setStatusMessage('Please log in before uploading.', 'error');
- return;
- }
-
- setLoading(true);
-
- try {
- folderPathCacheRef.current.clear();
-
- let extracted;
- try {
- extracted = await extractFilesFromDataTransfer(dataTransfer);
- } catch (error) {
- const message = error.message || 'Failed to process dropped files.';
- notifyApiError(error, message);
- return;
- }
-
- if (!extracted.length) {
- setStatusMessage('No files to upload.', 'info');
- return;
- }
- const baseFolderId =
- targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
-
- for (const { file, segments } of extracted) {
- // eslint-disable-next-line no-await-in-loop
- const destinationId = segments.length
- ? await ensureFolderPathOnServer(baseFolderId, segments)
- : baseFolderId;
-
- const uploadTarget =
- destinationId ??
- (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
-
- // eslint-disable-next-line no-await-in-loop
- await uploadFile(file, uploadTarget);
- }
-
- await refreshCurrentFolder();
-
- if (
- targetFolderId &&
- targetFolderId !== 'root' &&
- targetFolderId !== selectedFolder
- ) {
- await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
- }
- } catch (error) {
- notifyApiError(error, 'Failed to upload files.');
- } finally {
- setLoading(false);
- }
- },
- [
- token,
- extractFilesFromDataTransfer,
- ensureFolderPathOnServer,
- uploadFile,
- refreshCurrentFolder,
- selectedFolder,
- ensureFolderData,
- notifyApiError,
- setStatusMessage,
- ],
- );
-
- const normalizeDocumentId = (value) => {
- if (!value) return null;
- if (typeof value === 'object' && value.id) {
- return value.id;
- }
- return value;
- };
-
- const moveDocumentsToFolder = useCallback(
- async (documentIds, targetFolderId) => {
- const uniqueIds = Array.from(
- new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
- );
- if (!uniqueIds.length) return;
-
- const uniqueIdSet = new Set(uniqueIds);
- const target = targetFolderId === 'root' ? null : targetFolderId;
- const targetLabel =
- target === null
- ? DEFAULT_FOLDER_NAME
- : folderLabelMap.get(targetFolderId) || 'target folder';
-
- const movedDocs = uniqueIds
- .map((id) => {
- const doc = documentLookup.get(id);
- if (!doc) {
- return null;
- }
- return {
- id,
- sourceFolderId: doc.folder_id ?? null,
- document: doc,
- };
- })
- .filter(Boolean);
-
- const updatedDocsMap = new Map();
- const resolveTargetName = () => {
- if (!targetLabel) {
- return null;
- }
- const segments = String(targetLabel).split('/');
- return segments[segments.length - 1] || targetLabel;
- };
- const targetName = resolveTargetName();
-
- movedDocs.forEach(({ id, document }) => {
- if (!document) {
- return;
- }
- const updated = {
- ...document,
- folder_id: target,
- };
- if (targetLabel) {
- updated.folder_path = targetLabel;
- if (targetName) {
- updated.folder_name = targetName;
- }
- } else if (target === null) {
- updated.folder_path = DEFAULT_FOLDER_NAME;
- updated.folder_name = DEFAULT_FOLDER_NAME;
- }
- updatedDocsMap.set(id, updated);
- });
-
- const pruneRowCollection = (collection) =>
- collection.filter((key) => {
- if (!isDocumentRowKey(key)) {
- return true;
- }
- const id = getRowId(key);
- return id ? !uniqueIdSet.has(id) : true;
- });
-
- setLoading(true);
- try {
- if (uniqueIds.length === 1) {
- await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target });
- } else {
- await api.post('/documents/bulk/move', {
- document_ids: uniqueIds,
- folder_id: target,
- });
- }
-
- const count = uniqueIds.length;
- const suffix = count === 1 ? '' : 's';
- setStatusMessage(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
-
- if (updatedDocsMap.size) {
- mapDocumentCaches((doc) => {
- if (!doc || !uniqueIdSet.has(doc.id)) {
- return doc;
- }
- const updated = updatedDocsMap.get(doc.id);
- if (updated) {
- return updated;
- }
- return { ...doc, folder_id: target };
- });
- } else {
- mapDocumentCaches((doc) => {
- if (!doc || !uniqueIdSet.has(doc.id)) {
- return doc;
- }
- return { ...doc, folder_id: target };
- });
- }
-
- if (uniqueIdSet.size) {
- setSearchResults((prev) => {
- if (!Array.isArray(prev) || !prev.length) {
- return prev;
- }
- const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id));
- return filtered.length === prev.length ? prev : filtered;
- });
- setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
- setFolderContents((prev) => {
- if (!prev.size) {
- return prev;
- }
- let changed = false;
- const next = new Map(prev);
- movedDocs.forEach(({ id, sourceFolderId }) => {
- const sourceKey = sourceFolderId || 'root';
- const entry = next.get(sourceKey);
- if (!entry?.documents?.length) {
- return;
- }
- const filteredDocs = entry.documents.filter((doc) => doc.id !== id);
- if (filteredDocs.length !== entry.documents.length) {
- changed = true;
- next.set(sourceKey, { ...entry, documents: filteredDocs });
- }
- });
- return changed ? next : prev;
- });
-
- setSelectedEntries((prev) => pruneRowCollection(prev));
- setSelectionOrder((prev) => pruneRowCollection(prev));
- selectionOrderRef.current = pruneRowCollection(selectionOrderRef.current);
- if (
- selectionAnchorRef.current &&
- isDocumentRowKey(selectionAnchorRef.current) &&
- uniqueIdSet.has(getRowId(selectionAnchorRef.current))
- ) {
- selectionAnchorRef.current = null;
- }
- if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) {
- setFocusedDocumentId(null);
- }
- if (
- focusedRowKey &&
- isDocumentRowKey(focusedRowKey) &&
- uniqueIdSet.has(getRowId(focusedRowKey))
- ) {
- setFocusedRowKey(null);
- }
- }
-
- if (targetFolderId && targetFolderId !== selectedFolder) {
- await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
- }
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to move documents.';
- notifyApiError(error, message);
- } finally {
- setLoading(false);
- }
- },
- [
- documentLookup,
- ensureFolderData,
- folderLabelMap,
- focusedDocumentId,
- mapDocumentCaches,
- notifyApiError,
- selectedFolder,
- setDocuments,
- setFolderContents,
- setFocusedDocumentId,
- focusedRowKey,
- setFocusedRowKey,
- setSelectionOrder,
- setSelectedEntries,
- setStatusMessage,
- selectionAnchorRef,
- selectionOrderRef,
- ],
- );
-
- const handleThumbnailRegeneration = useCallback(
- async (documentId) => {
- if (!token) {
- setStatusMessage('Log in to manage assets.', 'error');
- return;
- }
- setLoading(true);
- try {
- await api.post(`/documents/${documentId}/assets`, null, {
- params: { force: true },
- });
- setStatusMessage('Document re-analysis queued.', 'info');
- await refreshCurrentFolder();
- } catch (error) {
- const message =
- error.response?.data?.error || 'Failed to request thumbnail generation.';
- notifyApiError(error, message);
- } finally {
- setLoading(false);
- }
- },
- [token, refreshCurrentFolder, notifyApiError, setStatusMessage],
- );
-
- const ensurePreviewData = useCallback(
- async (documentId) => {
- if (!documentId) return null;
-
- const findInCache = () => {
- const pool = searchResults ?? documents;
- return pool.find((item) => item.id === documentId) || null;
- };
-
- let doc = findInCache();
-
- if (!doc) {
- const { data } = await api.get(`/documents/${documentId}`);
- const hydratedDetail = assetManager.hydrateDetail(data);
- const fetched = hydratedDetail?.document || data.document || data;
- doc = fetched ? assetManager.hydrateDocument(fetched) : null;
- if (!doc) {
- throw new Error('Document metadata unavailable.');
- }
-
- setDocuments((prev) => {
- if (prev.some((item) => item.id === doc.id)) {
- return prev;
- }
- return [doc, ...prev];
- });
- }
-
- if (!previewReturnPathRef.current) {
- const fallbackFolderId = doc?.folder_id || 'root';
- previewReturnPathRef.current =
- fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
- }
-
- await ensurePreviewUrl(documentId, { force: false });
- setActivePreviewId(documentId);
- return doc;
- },
- [
- searchResults,
- documents,
- assetManager,
- setDocuments,
- ensurePreviewUrl,
- setActivePreviewId,
- ],
- );
-
- const openDocumentPreview = useCallback(
- (documentId, { replace = false } = {}) => {
- if (!documentId) return;
- detailPanelControlRef.current.close();
- previewReturnPathRef.current = `${location.pathname}${location.search}`;
- navigate(`/documents/${documentId}`, { replace });
- },
- [navigate, location.pathname, location.search],
- );
-
- const closeDocumentPreview = useCallback(
- (folderId = null) => {
- const fallbackPath = previewReturnPathRef.current;
- previewReturnPathRef.current = null;
-
- if (fallbackPath) {
- navigate(fallbackPath, { replace: false });
- return;
- }
-
- const targetId = folderId || selectedFolder || 'root';
- const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
- navigate(path, { replace: false });
- },
- [navigate, selectedFolder],
- );
-
- const handleDocumentsDelete = useCallback(
- async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
- if (!documentIds || documentIds.length === 0) {
- return false;
- }
-
- if (!token) {
- setStatusMessage('Log in to manage documents.', 'error');
- return false;
- }
-
- if (manageLoading) {
- setLoading(true);
- }
-
- try {
- await Promise.all(
- documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)),
- );
-
- removeDocumentsFromCaches(documentIds);
-
- if (documentIds.includes(previewDocumentId)) {
- closeDocumentPreview();
- }
-
- if (showMessage) {
- const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.';
- setStatusMessage(message, 'success');
- }
-
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to delete document.';
- notifyApiError(error, message);
- return false;
- } finally {
- if (manageLoading) {
- setLoading(false);
- }
- }
- },
- [
- token,
- previewDocumentId,
- closeDocumentPreview,
- removeDocumentsFromCaches,
- notifyApiError,
- setStatusMessage,
- setLoading,
- ],
- );
-
- useEffect(() => {
- if (!previewDocumentId) return;
- const handleKeyDown = (event) => {
- if (event.key === 'Escape') {
- closeDocumentPreview();
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [previewDocumentId, closeDocumentPreview]);
-
- useEffect(() => {
- if (!previewDocumentId) {
- return;
- }
-
- let cancelled = false;
-
- ensurePreviewData(previewDocumentId).catch((error) => {
- if (cancelled) {
- return;
- }
- notifyApiError(error, 'Failed to open document preview.');
- closeDocumentPreview();
- });
-
- return () => {
- cancelled = true;
- };
- }, [previewDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
-
- const handleDocumentTitleUpdate = useCallback(
- async (documentId, nextTitle) => {
- const trimmed = nextTitle.trim();
- if (!trimmed) {
- setStatusMessage('Document title cannot be empty.', 'error');
- return false;
- }
-
- setLoading(true);
- try {
- const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
- const updatedDocument = extractDocumentFromResponse(data);
-
- updateDocumentCaches(documentId, (doc) => {
- if (updatedDocument) {
- return { ...doc, ...updatedDocument };
- }
- return { ...doc, title: trimmed };
- });
-
- setStatusMessage('Document title updated.', 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to update document title.';
- notifyApiError(error, message);
- return false;
- } finally {
- setLoading(false);
- }
- },
- [notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse],
- );
-
- const handleDocumentIssuedUpdate = useCallback(
- async (documentId, nextIssuedDate) => {
- setLoading(true);
- const payload = { issued_at: nextIssuedDate || null };
- try {
- const { data } = await api.patch(`/documents/${documentId}`, payload);
- const updatedDocument = extractDocumentFromResponse(data);
-
- updateDocumentCaches(documentId, (doc) => {
- if (updatedDocument) {
- return { ...doc, ...updatedDocument };
- }
- return { ...doc, issued_at: payload.issued_at };
- });
-
- const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
- setStatusMessage(message, 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to update issued date.';
- notifyApiError(error, message);
- return false;
- } finally {
- setLoading(false);
- }
- },
- [extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches],
- );
-
- const applyTagRemovalToCaches = useCallback(
- (documentId, tagId) => {
- if (!documentId || !tagId) {
- return;
- }
-
- updateDocumentCaches(documentId, (doc) => {
- if (!Array.isArray(doc.tags)) {
- return doc;
- }
- const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
- if (nextTags.length === doc.tags.length) {
- return doc;
- }
- return { ...doc, tags: nextTags };
- });
- },
- [updateDocumentCaches],
- );
-
- const handleTagRemove = useCallback(
- async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
- if (!documentId || !tagId) {
- return false;
- }
-
- try {
- await api.delete(`/documents/${documentId}/tags/${tagId}`);
- applyTagRemovalToCaches(documentId, tagId);
- if (refreshTagList) {
- await refreshTags();
- }
- if (showMessage) {
- setStatusMessage('Tag removed.', 'success');
- }
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to remove tag.';
- notifyApiError(error, message);
- return false;
- }
- },
- [refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches],
- );
-
- const handleTagAdd = useCallback(
- async (document, label, extras = null) => {
- const normalizedLabel = tagManager.normalizeLabel(label);
- const optionCandidate =
- extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
- const input =
- extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
-
- let tag = null;
- if (optionCandidate && optionCandidate.id) {
- tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
- }
- if (!tag) {
- tag =
- tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
- }
- try {
- if (!tag) {
- const payload = tagManager.buildPayload({ label: normalizedLabel });
- const { data } = await api.post('/tags', payload);
- tag = data;
- await refreshTags();
- }
- await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
- setStatusMessage('Tag assigned.', 'success');
- if (input && typeof input === 'object') {
- input.value = '';
- }
- await refreshCurrentFolder();
- } catch (error) {
- notifyApiError(error, 'Failed to assign tag.');
- }
- },
- [tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
- );
-
- const handleDocumentTagAttach = useCallback(
- async ({ documentId, tagId, tag: tagData = null }) => {
- if (!documentId || !tagId) {
- return false;
- }
-
- const resolveTagForCache = () => {
- const lookupTag = tagLookupById.get(tagId);
- const source = lookupTag ?? tagData;
- if (!source || source.id == null || typeof source.label !== 'string') {
- return null;
- }
- return {
- id: source.id,
- label: source.label,
- color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
- };
- };
-
- try {
- await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
- updateDocumentCaches(documentId, (doc) => {
- if (!doc) {
- return doc;
- }
- const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
- if (currentTags.some((existing) => existing?.id === tagId)) {
- return doc;
- }
- const resolvedTag = resolveTagForCache();
- if (!resolvedTag) {
- return doc;
- }
- return { ...doc, tags: [...currentTags, resolvedTag] };
- });
- setStatusMessage('Tag assigned.', 'success');
- if (documentsViewMode !== 'desk') {
- await refreshCurrentFolder();
- }
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to assign tag.';
- notifyApiError(error, message);
- return false;
- }
- },
- [
- refreshCurrentFolder,
- documentsViewMode,
- notifyApiError,
- setStatusMessage,
- updateDocumentCaches,
- tagLookupById,
- ],
- );
-
-
- const handleFolderDelete = useCallback(
- async (folderId, { showMessage = true, manageLoading = true } = {}) => {
- if (!token) {
- if (showMessage) {
- setStatusMessage('Log in to manage folders.', 'error');
- }
- return false;
- }
- if (!folderId || folderId === 'root') {
- if (showMessage) {
- setStatusMessage('The root folder cannot be removed.', 'error');
- }
- return false;
- }
-
- if (manageLoading) {
- setLoading(true);
- }
-
- try {
- const contents = await ensureFolderData(folderId, {
- force: true,
- prefetchDepth: 1,
- });
- const hasChildren = (contents.subfolders || []).length > 0;
- const hasDocs = (contents.documents || []).length > 0;
- if (hasChildren || hasDocs) {
- if (showMessage) {
- setStatusMessage('Folder must be empty before it can be deleted.', 'error');
- }
- return false;
- }
-
- await api.delete(`/folders/${folderId}`);
-
- setFolderNodes((prev) => {
- const next = new Map(prev);
- const node = next.get(folderId);
- next.delete(folderId);
- if (node) {
- const parentId = node.parentId || 'root';
- const parentNode = next.get(parentId);
- if (parentNode) {
- const remaining = parentNode.children.filter((id) => id !== folderId);
- next.set(parentId, {
- ...parentNode,
- children: remaining,
- hasChildren: remaining.length > 0,
- });
- }
- }
- return next;
- });
-
- setFolderContents((prev) => {
- const next = new Map(prev);
- next.delete(folderId);
- return next;
- });
-
- if (selectedFolder === folderId) {
- const node = folderNodes.get(folderId);
- const parentId = node?.parentId || 'root';
- setSelectedFolder(parentId);
- const parentContents = await ensureFolderData(parentId, {
- force: true,
- prefetchDepth: 1,
- });
- applySelectedFolder(parentId, parentContents);
- } else if (selectedFolder !== 'root') {
- await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
- }
-
- if (showMessage) {
- setStatusMessage('Folder deleted.', 'success');
- }
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to delete folder.';
- notifyApiError(error, message);
- if (showMessage) {
- setStatusMessage(message, 'error');
- }
- return false;
- } finally {
- if (manageLoading) {
- setLoading(false);
- }
- }
- },
- [
- token,
- ensureFolderData,
- selectedFolder,
- folderNodes,
- applySelectedFolder,
- notifyApiError,
- setStatusMessage,
- setLoading,
- ],
- );
-
- const handleDeleteSelection = useCallback(async () => {
- const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
- const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
-
- if (docIds.length === 0 && folderIds.length === 0) {
- return;
- }
-
- const parts = [];
- if (docIds.length) {
- parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`);
- }
- if (folderIds.length) {
- parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`);
- }
- const descriptor = parts.join(' and ');
- const confirmation = parts.length === 1
- ? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`
- : `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`;
-
- if (!window.confirm(confirmation)) {
- return;
- }
-
- setLoading(true);
- let docsOk = true;
- let foldersOk = true;
-
- try {
- if (docIds.length) {
- docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false });
- }
-
- if (folderIds.length) {
- for (const folderId of folderIds) {
- const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
- if (!success) {
- foldersOk = false;
- }
- }
- }
- } finally {
- setLoading(false);
- }
-
- if (!docsOk || !foldersOk) {
- setStatusMessage('Some items could not be deleted. Ensure folders are empty before deletion.', 'error');
- return;
- }
-
- clearDocumentSelection();
-
- const successParts = [];
- if (docIds.length) {
- successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.');
- }
- if (folderIds.length) {
- successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.');
- }
-
- setStatusMessage(successParts.join(' '), 'success');
- }, [
- selectedDocumentIds,
- selectedFolderIds,
- handleDocumentsDelete,
- handleFolderDelete,
- clearDocumentSelection,
- setLoading,
- setStatusMessage,
- ]);
-
- 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 handleFolderRename = useCallback(
- async (folderId, nextName) => {
- if (!token) {
- setStatusMessage('Log in to rename folders.', 'error');
- return false;
- }
- if (!folderId || folderId === 'root') {
- setStatusMessage('The root folder cannot be renamed.', 'error');
- return false;
- }
- const trimmed = typeof nextName === 'string' ? nextName.trim() : '';
- if (!trimmed) {
- setStatusMessage('Folder name cannot be empty.', 'error');
- return false;
- }
-
- setLoading(true);
- try {
- await api.patch(`/folders/${folderId}`, { name: trimmed });
-
- setFolderNodes((prev) => {
- const next = new Map(prev);
- const node = next.get(folderId);
- if (node) {
- next.set(folderId, { ...node, name: trimmed });
- }
- return next;
- });
-
- setFolderContents((prev) => {
- if (!prev.has(folderId)) {
- return prev;
- }
- const next = new Map(prev);
- const existing = next.get(folderId) || {};
- const folderInfo = existing.folder
- ? { ...existing.folder, name: trimmed }
- : { id: folderId, name: trimmed };
- next.set(folderId, { ...existing, folder: folderInfo });
- return next;
- });
-
- setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev));
-
- setStatusMessage('Folder renamed.', 'success');
- return true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to rename folder.';
- notifyApiError(error, message);
- return false;
- } finally {
- setLoading(false);
- }
- },
- [token, notifyApiError, setStatusMessage],
- );
-
- const handleFolderCreate = useCallback(
- async (name) => {
- if (!token) {
- setStatusMessage('Log in to create folders.', 'error');
- return false;
- }
- if (!name.trim()) {
- setStatusMessage('Folder name cannot be empty.', 'error');
- return false;
- }
- const payload = {
- name: name.trim(),
- parent_id: selectedFolder === 'root' ? null : selectedFolder,
- };
- setLoading(true);
- let succeeded = false;
- try {
- const { data } = await api.post('/folders', payload);
- setStatusMessage('Folder created.', 'success');
- setFolderNodes((prev) => {
- const next = new Map(prev);
- const parentId = payload.parent_id || 'root';
- const parentNode = next.get(parentId);
- if (parentNode) {
- next.set(parentId, {
- ...parentNode,
- children: parentNode.children.concat([data.folder.id]),
- loaded: true,
- hasChildren: true,
- });
- }
- next.set(data.folder.id, {
- id: data.folder.id,
- name: data.folder.name,
- parentId: parentId,
- children: [],
- expanded: false,
- loaded: false,
- hasChildren: false,
- });
- return next;
- });
- await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
- await selectFolder(data.folder.id, { immediate: true });
- succeeded = true;
- } catch (error) {
- const message = error.response?.data?.error || 'Failed to create folder.';
- notifyApiError(error, message);
- succeeded = false;
- } finally {
- setLoading(false);
- }
- return succeeded;
- },
- [token, selectedFolder, ensureFolderData, notifyApiError, setStatusMessage, selectFolder],
- );
-
- 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 openSettings = useCallback(() => {
- navigate('/settings');
- }, [navigate]);
-
- useEffect(() => {
- if (!token) return undefined;
-
- if (!isFilterActive) {
- setSearchResults(null);
- setSearchLoading(false);
- return undefined;
- }
-
- let cancelled = false;
- let started = false;
- setSearchLoading(true);
-
- const debounce = setTimeout(async () => {
- started = true;
- setLoading(true);
- try {
- const params = {};
- const trimmedQuery = searchQuery.trim();
- if (trimmedQuery.length) {
- params.query = trimmedQuery;
- }
- if (activeTagFilters.length) {
- const onlyUntagged = activeTagFilters.length === 1
- && activeTagFilters[0] === TAG_FILTER_UNTAGGED;
- if (onlyUntagged) {
- params.tags = 'none';
- } else {
- const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED);
- if (tagIds.length) {
- params.tags = tagIds.join(',');
- }
- }
- }
- if (activeCorrespondentFilters.length) {
- params.correspondents = activeCorrespondentFilters.join(',');
- }
- const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
- if (folderIdentifier) {
- params.folder_id = folderIdentifier;
- }
- if (!searchIncludeDescendants) {
- params.include_descendants = false;
- }
- if (documentsSortField) {
- params.sort = documentsSortField;
- }
- if (documentsSortDirection) {
- params.dir = documentsSortDirection;
- }
- const { data } = await api.get('/documents', { params });
- if (cancelled) return;
-
- const results = assetManager.hydrateDocuments(data || []);
- setSearchResults(results);
-
- if (!results.length) {
- setSearchLoading(false);
- setSelectedEntries([]);
- setFocusedDocumentId(null);
- selectionOrderRef.current = [];
- setSelectionOrder([]);
- selectionAnchorRef.current = null;
- return;
- }
-
- const resultKeys = results
- .map((doc) => resolveDocumentRowKey(doc.id))
- .filter(Boolean);
-
- let targetKey = null;
- let nextSelectionKeys = [];
-
- setSelectedEntries((previous) => {
- const previousDocKeys = previous.filter(isDocumentRowKey);
- const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
- if (filtered.length) {
- targetKey = filtered[filtered.length - 1];
- nextSelectionKeys = filtered;
- return filtered;
- }
- targetKey = null;
- nextSelectionKeys = [];
- return [];
- });
-
- selectionOrderRef.current = nextSelectionKeys;
- setSelectionOrder(nextSelectionKeys);
-
- setFocusedDocumentId((previous) => {
- if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
- return previous;
- }
- return null;
- });
-
- selectionAnchorRef.current = targetKey;
-
- // rely on hydrated search results; assets refresh on demand
- } catch (error) {
- if (cancelled) return;
- notifyApiError(error, 'Search failed. Please try again.');
- setSearchResults(null);
- } finally {
- if (!cancelled && started) {
- setLoading(false);
- setSearchLoading(false);
- }
- }
- }, 300);
-
- return () => {
- cancelled = true;
- clearTimeout(debounce);
- if (started) {
- setLoading(false);
- setSearchLoading(false);
- }
- };
- }, [
- token,
- isFilterActive,
- searchQuery,
- activeTagFilters,
- activeCorrespondentFilters,
- searchIncludeDescendants,
- documentsSortField,
- documentsSortDirection,
- selectedFolder,
- notifyApiError,
- assetManager,
- selectionOrderRef,
- selectionAnchorRef,
- setSelectedEntries,
- setSelectionOrder,
- setFocusedDocumentId,
- ]);
-
- useEffect(() => {
- if (!token) {
- setDropOverlayState((prev) => ({ ...prev, active: false }));
- dragCounterRef.current = 0;
- return undefined;
- }
-
- const handleDragEnter = (event) => {
- if (!hasFiles(event)) return;
- event.preventDefault();
- dragCounterRef.current += 1;
- setDropOverlayState({ active: true, folderName: currentFolderName });
- };
-
- const handleDragOver = (event) => {
- if (!hasFiles(event)) return;
- event.preventDefault();
- event.dataTransfer.dropEffect = 'copy';
- };
-
- const handleDragLeave = (event) => {
- if (!hasFiles(event)) return;
- dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
- if (dragCounterRef.current === 0) {
- setDropOverlayState((prev) => ({ ...prev, active: false }));
- }
- };
-
- const handleDrop = async (event) => {
- if (!hasFiles(event)) return;
- event.preventDefault();
- dragCounterRef.current = 0;
- setDropOverlayState((prev) => ({ ...prev, active: false }));
- await handleFileDrop(event.dataTransfer, selectedFolder);
- };
-
- const dropTarget = shellRef.current;
- if (!dropTarget) {
- return undefined;
- }
-
- dropTarget.addEventListener('dragenter', handleDragEnter);
- dropTarget.addEventListener('dragover', handleDragOver);
- dropTarget.addEventListener('dragleave', handleDragLeave);
- dropTarget.addEventListener('drop', handleDrop);
-
- return () => {
- dropTarget.removeEventListener('dragenter', handleDragEnter);
- dropTarget.removeEventListener('dragover', handleDragOver);
- dropTarget.removeEventListener('dragleave', handleDragLeave);
- dropTarget.removeEventListener('drop', handleDrop);
- dragCounterRef.current = 0;
- setDropOverlayState((prev) => ({ ...prev, active: false }));
- };
- }, [token, handleFileDrop, currentFolderName, selectedFolder]);
-
- 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 handleLogout = useCallback(async () => {
- try {
- setLoading(true);
- await api.post('/auth/logout');
- } catch (error) {
- console.warn('[Auth] Failed to revoke refresh token during logout', error);
- } finally {
- setLoading(false);
- appDispatch({ type: 'LOGOUT' });
- setStatusMessage('Logged out.', 'info');
- }
- }, [appDispatch, setStatusMessage]);
-
- const folderClickHandlers = useMemo(
- () => ({
- onToggle: async (folderId) => {
- const node = folderNodes.get(folderId);
- const nextExpanded = !(node?.expanded ?? false);
- if (nextExpanded) {
- try {
- await ensureFolderData(folderId, {
- includeDocuments: false,
- prefetchDepth: 1,
- });
- } catch (error) {
- notifyApiError(error, 'Failed to load folder.');
- }
- } else if (node && !node.loaded) {
- try {
- await ensureFolderData(folderId, {
- includeDocuments: false,
- prefetchDepth: 1,
- });
- } catch (error) {
- notifyApiError(error, 'Failed to load folder.');
- }
- }
- setFolderNodes((prev) => {
- const next = new Map(prev);
- const current = next.get(folderId);
- if (!current) return prev;
- next.set(folderId, { ...current, expanded: nextExpanded });
- return next;
- });
- },
- onSelect: selectFolder,
- onDrop: async (event, folderId) => {
- event.preventDefault();
- event.stopPropagation();
- event.currentTarget.classList.remove('is-drop-target');
-
- let folderIds = [];
- try {
- const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
- if (rawFolderList) {
- const parsed = JSON.parse(rawFolderList);
- if (Array.isArray(parsed)) {
- folderIds = parsed.filter(Boolean);
- }
- }
- } catch (error) {
- console.warn('[folders] Failed to parse folder list drag payload', error);
- }
-
- if (!folderIds.length) {
- let folderSourceId = draggedFolderId;
- if (!folderSourceId) {
- try {
- if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) {
- folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder');
- }
- } catch (error) {
- console.warn('[folders] Failed to read folder id from drag payload', error);
- }
- }
-
- if (folderSourceId) {
- folderIds = [folderSourceId];
- }
- }
-
- folderIds = Array.from(new Set(folderIds.filter(Boolean)));
-
- if (folderIds.length) {
- setDraggedFolderId(null);
- const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId));
- if (invalidMove) {
- setStatusMessage(
- 'Cannot move a folder into itself or one of its descendants.',
- 'error',
- );
- return;
- }
-
- for (const sourceId of folderIds) {
- // eslint-disable-next-line no-await-in-loop
- await moveFolder(sourceId, folderId);
- }
- }
-
- if (hasFiles(event)) {
- await handleFileDrop(event.dataTransfer, folderId);
- return;
- }
-
- let docIds = [];
- try {
- const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
- if (raw) {
- const parsed = JSON.parse(raw);
- if (Array.isArray(parsed)) {
- docIds = parsed.filter(Boolean);
- }
- }
- } catch (error) {
- console.warn('[documents] Failed to parse document list drag payload', error);
- }
-
- if (!docIds.length) {
- try {
- const single = event.dataTransfer.getData('application/x-papercrate-doc');
- if (single) {
- docIds = [single];
- }
- } catch (error) {
- console.warn('[documents] Failed to read single document drag payload', error);
- }
- }
-
- if (!docIds.length && draggedDocumentIds.length) {
- docIds = draggedDocumentIds;
- }
-
- docIds = Array.from(new Set(docIds));
-
- if (!docIds.length || folderId === selectedFolder) {
- return;
- }
-
- setDraggedDocumentIds([]);
- await moveDocumentsToFolder(docIds, folderId);
- },
- onDragOver: (event, folderId) => {
- const folderDragActive = Boolean(draggedFolderId);
- if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
- return;
- }
-
- if (hasFiles(event)) {
- event.preventDefault();
- event.dataTransfer.dropEffect = 'copy';
- event.currentTarget.classList.add('is-drop-target');
- return;
- }
-
- if (draggedDocumentIds.length || folderDragActive) {
- event.preventDefault();
- event.dataTransfer.dropEffect = 'move';
- event.currentTarget.classList.add('is-drop-target');
- }
- },
- onDragLeave: (event) => {
- event.currentTarget.classList.remove('is-drop-target');
- },
- }),
- [
- draggedDocumentIds,
- draggedFolderId,
- ensureFolderData,
- folderNodes,
- handleFileDrop,
- isInvalidFolderDrop,
- moveDocumentsToFolder,
- moveFolder,
- notifyApiError,
- selectFolder,
- selectedFolder,
- setDraggedDocumentIds,
- setDraggedFolderId,
- setFolderNodes,
- setStatusMessage,
- ],
- );
-
- const selectedDocument = useMemo(() => {
- if (!focusedDocumentId) {
- return null;
- }
- const list = searchResults ?? documents;
- return list.find((doc) => doc.id === focusedDocumentId) || null;
- }, [searchResults, documents, focusedDocumentId]);
-
-
- const orderedSelectedDocuments = useMemo(() => {
- const ordered = [];
- const seen = new Set();
- const pushDoc = (doc) => {
- if (doc?.id && !seen.has(doc.id)) {
- ordered.push(doc);
- seen.add(doc.id);
- }
- };
-
- selectionOrder.forEach((key) => {
- if (!isDocumentRowKey(key)) {
- return;
- }
- const docId = getRowId(key);
- const doc = documentLookup.get(docId) || null;
- pushDoc(doc);
- });
-
- selectedDocumentIds.forEach((id) => {
- if (seen.has(id)) return;
- const doc = documentLookup.get(id) || null;
- pushDoc(doc);
- });
-
- return ordered;
- }, [selectionOrder, documentLookup, selectedDocumentIds]);
-
- const {
- detailPanelOpen,
- detailPanelDocument,
- openDetailPanel,
- closeDetailPanel,
- } = useDetailPanel({
- documentLookup,
- orderedSelectedDocuments,
- });
-
- detailPanelControlRef.current = {
- open: openDetailPanel,
- close: closeDetailPanel,
- };
-
- const handleEntryPointerCore = useEntryPointerCore({
- resolveDocumentRowKey,
- resolveFolderRowKey,
- onSelectDocument: (documentId, event, { rowKey }) => {
- const key = rowKey || resolveDocumentRowKey(documentId);
- if (key) {
- handleEntrySelection(key, event);
- }
- },
- onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => {
- const key = rowKey || resolveFolderRowKey(folderId);
- if (key) {
- handleEntrySelection(key, event);
- }
- if (!modifierClick && primaryClick) {
- selectFolder(folderId);
- }
- },
- });
-
- const inspectDocument = useCallback(
- (documentId) => {
- if (!documentId) {
- return;
- }
- openDetailPanel({ documentIds: [documentId] });
- },
- [openDetailPanel],
- );
-
- 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]);
-
- useEffect(() => {
- if (!orderedSelectedDocuments.length) {
- return;
- }
-
- const visited = new Set();
-
- orderedSelectedDocuments.forEach((doc) => {
- const folderId = doc?.folder_id;
- if (!folderId) {
- return;
- }
- let currentId = folderId;
- let guard = 0;
- while (currentId && currentId !== 'root' && guard < 32) {
- guard += 1;
- if (visited.has(currentId)) {
- break;
- }
- visited.add(currentId);
- const node = folderNodes.get(currentId);
- if (!node) {
- if (!detailFolderFetchRef.current.has(currentId)) {
- detailFolderFetchRef.current.add(currentId);
- ensureFolderData(currentId, { force: false, includeDocuments: false })
- .catch((error) => {
- console.warn('Failed to preload folder metadata for detail path', currentId, error);
- })
- .finally(() => {
- detailFolderFetchRef.current.delete(currentId);
- });
- }
- break;
- }
-
- const parentId = node.parentId ?? 'root';
- if (!parentId || parentId === 'root') {
- break;
- }
- currentId = parentId;
- }
- });
- }, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
-
- const resolveFolderPath = useCallback(
- (folderId) => {
- if (!folderId || folderId === 'root') {
- return [];
- }
-
- const segments = [];
- const visited = new Set();
- let currentId = folderId;
- let guard = 0;
-
- while (currentId && guard < 32 && !visited.has(currentId)) {
- guard += 1;
- visited.add(currentId);
-
- if (currentId === 'root') {
- break;
- }
-
- const node = folderNodes.get(currentId);
- if (!node) {
- segments.push({ id: currentId, name: '…' });
- break;
- }
-
- segments.push({ id: node.id, name: node.name || 'Folder' });
-
- const parentId = node.parentId ?? 'root';
- if (!parentId || parentId === 'root') {
- segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
- break;
- }
-
- currentId = parentId;
- }
-
- if (!segments.some((segment) => segment.id === 'root')) {
- segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
- }
-
- return segments.reverse();
- },
- [folderNodes],
- );
-
- const selectedPreviewEntry = useMemo(() => {
- if (!selectedDocument) {
- return null;
- }
- return previewEntries.get(selectedDocument.id) || null;
- }, [selectedDocument, previewEntries]);
-
- const previewWorkspaceEntry = useMemo(() => {
- if (!previewDocumentId) {
- return null;
- }
- return previewEntries.get(previewDocumentId) || null;
- }, [previewDocumentId, previewEntries]);
-
- const previewWorkspaceDocument = useMemo(() => {
- if (!previewDocumentId) return null;
- const pool = searchResults ?? documents;
- return pool.find((doc) => doc.id === previewDocumentId) || null;
- }, [previewDocumentId, searchResults, documents]);
-
- const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
-
- const resolveThumbnailUrlForDoc = useCallback(
- (doc) =>
- resolveDocumentAssetUrl(doc, 'thumbnail', {
- ensureAssetUrl,
- getAsset: getDocumentAsset,
- }),
- [ensureAssetUrl, getDocumentAsset],
- );
-
- const handleDocumentsViewModeChange = useCallback((mode) => {
- const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
- setDocumentsViewMode((previous) => {
- if (next !== previous && typeof window !== 'undefined') {
- try {
- window.sessionStorage.setItem('papercrate_view_mode', next);
- } catch (error) {
- console.warn('[view-mode] failed to persist mode', error);
- }
- }
- return next;
- });
- }, []);
-
- const handleDeskExit = useCallback(() => {
- const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
- ? lastNonDeskViewRef.current
- : 'list';
- handleDocumentsViewModeChange(fallback);
- }, [handleDocumentsViewModeChange]);
-
- const handleTenantSelect = useCallback(
- async (tenantOption, { refreshOnly = false } = {}) => {
- const requestedTenantId = tenantOption?.id ?? null;
- if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
- return;
- }
-
- setLoading(true);
- try {
- if (!refreshOnly) {
- setStatusMessage('Switching tenant…', 'info');
- }
-
- if (refreshOnly) {
- const { data } = await api.get('/auth/tenants');
- appDispatch({
- type: 'SET_TENANTS',
- tenants: Array.isArray(data?.tenants) ? data.tenants : [],
- });
- return;
- }
-
- const { data } = await api.post('/auth/select-tenant', { tenant_id: requestedTenantId });
- if (!data?.access_token) {
- throw new Error('Missing access token in tenant switch response.');
- }
-
- appDispatch({ type: 'LOGOUT' });
- resetWorkspaceState();
-
- appDispatch({
- type: 'LOGIN_SUCCESS',
- token: data.access_token,
- tenant: data.tenant || null,
- });
-
- api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
- tokenRef.current = data.access_token;
- tenantIdRef.current = data?.tenant?.id ?? null;
-
- if (Array.isArray(data?.tenants)) {
- appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
- }
-
- handleDocumentsViewModeChange('list');
- navigate('/documents', { replace: true });
-
- await Promise.all([refreshTags(), refreshCorrespondents()]);
- await loadFolder('root', { showLoading: false, preserveSearch: false });
-
- const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
- setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
- } catch (error) {
- notifyApiError(error, 'Failed to switch tenant.');
- } finally {
- setLoading(false);
- }
- },
- [
- currentTenantId,
- appDispatch,
- notifyApiError,
- setStatusMessage,
- resetWorkspaceState,
- handleDocumentsViewModeChange,
- navigate,
- refreshTags,
- refreshCorrespondents,
- loadFolder,
- ],
- );
-
- const documentsTableProps = useMemo(
- () => ({
- currentFolderName,
- breadcrumbs,
- onRefresh: refreshCurrentFolder,
- subfolders: currentSubfolders,
- documents,
- searchResults,
- isFilterActive,
- onFolderSelect: selectFolder,
- onFolderDrop: folderClickHandlers.onDrop,
- onFolderDragOver: folderClickHandlers.onDragOver,
- onFolderDragLeave: folderClickHandlers.onDragLeave,
- onFolderDragStart: handleFolderDragStart,
- onFolderDragEnd: handleFolderDragEnd,
- draggedFolderId,
- onFolderRename: handleFolderRename,
- onDocumentOpen: openDocumentPreview,
- onDocumentRename: handleDocumentTitleUpdate,
- selectedDocumentIds,
- selectedFolderIds,
- focusedRowKey,
- draggingDocumentIds: draggedDocumentIds,
- onDocumentDragStart: handleDocumentDragStart,
- onDocumentDragEnd: handleDocumentDragEnd,
- isSearchLoading: searchLoading,
- tagLookupById,
- activeCorrespondentIds: activeCorrespondentFilters,
- selectedEntries,
- onFocusedRowChange: setFocusedRowKey,
- ensureAssetUrl,
- getDocumentAsset,
- onTagClick: toggleTagFilter,
- onCorrespondentClick: toggleCorrespondentFilter,
- onDocumentTagDrop: handleDocumentTagDrop,
- viewMode: documentsViewMode,
- sortField: documentsSortField,
- sortDirection: documentsSortDirection,
- onSortFieldChange: handleDocumentsSortFieldChange,
- onSortDirectionToggle: handleDocumentsSortDirectionToggle,
- searchIncludeDescendants,
- onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
- onViewModeChange: handleDocumentsViewModeChange,
- onClearSelection: clearDocumentSelection,
- onDeleteSelection: handleDeleteSelection,
- onEntryPointer: handleEntryPointerCore,
- onInspectDocument: inspectDocument,
- onEntrySelection: handleEntrySelection,
- tags,
- correspondents,
- documentLookup,
- onBulkTagAdd: handleBulkTagAddFromDetail,
- onBulkTagRemove: handleBulkTagRemoveFromDetail,
- onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
- onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
- onBulkReanalyze: handleBulkSelectionReanalyze,
- folderOptions,
- onMoveDocumentsToFolder: moveDocumentsToFolder,
- }),
- [
- activeCorrespondentFilters,
- breadcrumbs,
- clearDocumentSelection,
- currentFolderName,
- currentSubfolders,
- documents,
- documentsViewMode,
- documentsSortField,
- documentsSortDirection,
- draggedDocumentIds,
- draggedFolderId,
- focusedRowKey,
- folderClickHandlers,
- handleDocumentDragEnd,
- handleDocumentDragStart,
- handleDocumentTagDrop,
- handleDocumentTitleUpdate,
- handleDocumentsViewModeChange,
- handleDocumentsSortFieldChange,
- handleDocumentsSortDirectionToggle,
- handleFolderDragEnd,
- handleFolderDragStart,
- handleFolderRename,
- handleEntryPointerCore,
- inspectDocument,
- handleEntrySelection,
- handleDeleteSelection,
- isFilterActive,
- openDocumentPreview,
- refreshCurrentFolder,
- searchLoading,
- searchResults,
- searchIncludeDescendants,
- selectFolder,
- selectedDocumentIds,
- selectedEntries,
- selectedFolderIds,
- setFocusedRowKey,
- tagLookupById,
- toggleCorrespondentFilter,
- toggleTagFilter,
- ensureAssetUrl,
- getDocumentAsset,
- tags,
- correspondents,
- documentLookup,
- handleBulkTagAddFromDetail,
- handleBulkTagRemoveFromDetail,
- handleBulkCorrespondentAdd,
- handleBulkCorrespondentRemove,
- handleBulkSelectionReanalyze,
- folderOptions,
- moveDocumentsToFolder,
- toggleSearchIncludeDescendants,
- ],
- );
-
- const sidebarProps = useMemo(
- () => ({
- folderNodes,
- onToggle: folderClickHandlers.onToggle,
- onSelect: folderClickHandlers.onSelect,
- onDrop: folderClickHandlers.onDrop,
- onDragOver: folderClickHandlers.onDragOver,
- onDragLeave: folderClickHandlers.onDragLeave,
- onDeleteFolder: handleFolderDelete,
- onRenameFolder: handleFolderRename,
- selectedFolder,
- onFolderDragStart: handleFolderDragStart,
- onFolderDragEnd: handleFolderDragEnd,
- draggedFolderId,
- onCreateFolder: handlePromptCreateFolder,
- creatingFolder,
- tags,
- untaggedFilterId: TAG_FILTER_UNTAGGED,
- activeTagIds: activeTagFilters,
- onToggleTagFilter: toggleTagFilter,
- onCreateTag: (label) => handleTagCreate({ label }),
- correspondents,
- activeCorrespondentIds: activeCorrespondentFilters,
- onToggleCorrespondentFilter: toggleCorrespondentFilter,
- onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
- appStatus,
- loading,
- previewActive,
- searchQuery,
- onSearchChange: handleSearchChange,
- onSearchSubmit: handleSearchSubmit,
- onSearchClear: clearFilters,
- isFilterActive,
- onLogout: handleLogout,
- status,
- tenantName,
- tenants: tenantOptions,
- activeTenantId: currentTenantId,
- onSelectTenant: handleTenantSelect,
- onOpenSettings: openSettings,
- }),
- [
- activeCorrespondentFilters,
- activeTagFilters,
- appStatus,
- clearFilters,
- correspondents,
- currentTenantId,
- folderClickHandlers,
- folderNodes,
- handleFolderDragEnd,
- handleFolderDragStart,
- handleFolderDelete,
- handleFolderRename,
- handleLogout,
- handleSearchChange,
- handleSearchSubmit,
- handleTenantSelect,
- loading,
- openSettings,
- previewActive,
- searchQuery,
- draggedFolderId,
- isFilterActive,
- selectedFolder,
- status,
- tags,
- tenantOptions,
- tenantName,
- toggleCorrespondentFilter,
- toggleTagFilter,
- handleTagCreate,
- handleCorrespondentCreate,
- handlePromptCreateFolder,
- creatingFolder,
- ],
- );
-
- const handleDetailPanelClose = useCallback(() => {
- closeDetailPanel();
- }, [closeDetailPanel]);
-
- const detailPanelProps = useMemo(
- () => ({
- document: detailPanelDocument,
- tags,
- tagLookupById,
- onTagAdd: handleTagAdd,
- onTagRemove: handleTagRemove,
- previewEntry: selectedPreviewEntry,
- onOpenPreview: openDocumentPreview,
- onPromoteSelection: promoteSelectionOrder,
- activePreviewId,
- onUpdateTitle: handleDocumentTitleUpdate,
- onUpdateIssued: handleDocumentIssuedUpdate,
- ensureAssetUrl,
- getDocumentAsset,
- ensurePreviewData,
- correspondents,
- onCorrespondentAdd: handleCorrespondentAdd,
- onCorrespondentRemove: handleCorrespondentRemove,
- resolveApiPath,
- onFolderNavigate: selectFolder,
- onClose: handleDetailPanelClose,
- resolveFolderPath,
- }),
- [
- activePreviewId,
- correspondents,
- detailPanelDocument,
- ensureAssetUrl,
- ensurePreviewData,
- getDocumentAsset,
- handleCorrespondentAdd,
- handleCorrespondentRemove,
- handleDetailPanelClose,
- handleDocumentTitleUpdate,
- handleDocumentIssuedUpdate,
- handleTagAdd,
- handleTagRemove,
- openDocumentPreview,
- promoteSelectionOrder,
- resolveFolderPath,
- selectFolder,
- selectedPreviewEntry,
- tags,
- tagLookupById,
- ],
- );
-
- const handleDeskDocumentStackSelect = useCallback(
- (docIds) => {
- if (!Array.isArray(docIds) || docIds.length === 0) {
- return;
- }
-
- const rowKeys = docIds
- .map((id) => resolveDocumentRowKey(id))
- .filter(Boolean);
-
- 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];
-
- applySelection(nextKeys, {
- anchor,
- interactedKeys: rowKeys,
- });
- },
- [applySelection, selectedEntries, selectionAnchorRef],
- );
-
- const handleDeskDocumentOpen = useCallback(
- (docId, { useSelection = false } = {}) => {
- const selectionDocIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
- let targetIds = [];
-
- if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) {
- targetIds = selectionDocIds;
- } else if (selectionDocIds.length) {
- targetIds = selectionDocIds;
- } else if (docId) {
- targetIds = [docId];
- }
-
- if (!targetIds.length) {
- return;
- }
-
- openDetailPanel({ documentIds: targetIds });
- },
- [openDetailPanel, selectedDocumentIds],
- );
-
- const handleDeskHelpOpen = useCallback(() => {
- setDeskHelpOpen(true);
- }, []);
-
- const handleDeskHelpClose = useCallback(() => {
- setDeskHelpOpen(false);
- }, []);
-
- 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,
- searchResults,
- breadcrumbs,
- currentFolderName,
- viewMode: documentsViewMode,
- onViewModeChange: handleDocumentsViewModeChange,
- onExit: handleDeskExit,
- onRefresh: refreshCurrentFolder,
- onDocumentOpen: handleDeskDocumentOpen,
- onInspectDocument: inspectDocument,
- onEntryPointer: handleEntryPointerCore,
- onDocumentStackSelect: handleDeskDocumentStackSelect,
- onPromoteSelection: promoteSelectionOrder,
- onOpenHelp: handleDeskHelpOpen,
- helpOpen: deskHelpOpen,
- onHelpClose: handleDeskHelpClose,
- tenantId: currentTenantId,
- viewId: deskViewId,
- selectedDocumentIds,
- selectedFolderIds,
- onClearSelection: clearDocumentSelection,
- detailPanelOpen,
- onCloseDetailPanel: handleDetailPanelClose,
- resolveThumbnailUrl: resolveThumbnailUrlForDoc,
- onAssignTagToDocument: handleDocumentTagDrop,
- onRemoveTagFromDocument: handleTagRemove,
- ensureAssetUrl,
- getDocumentAsset,
- activeTagIds: activeTagFilters,
- onDeleteSelection: handleDeleteSelection,
- tags,
- correspondents,
- documentLookup,
- tagLookupById,
- onBulkTagAdd: handleBulkTagAddFromDetail,
- onBulkTagRemove: handleBulkTagRemoveFromDetail,
- onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
- onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
- onBulkReanalyze: handleBulkSelectionReanalyze,
- folderOptions,
- onMoveDocumentsToFolder: moveDocumentsToFolder,
- searchIncludeDescendants,
- onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
- }),
- [
- documents,
- searchResults,
- breadcrumbs,
- currentFolderName,
- documentsViewMode,
- handleDocumentsViewModeChange,
- handleDeskExit,
- refreshCurrentFolder,
- handleDeskDocumentStackSelect,
- handleEntryPointerCore,
- promoteSelectionOrder,
- handleDeskHelpOpen,
- handleDeskHelpClose,
- currentTenantId,
- deskViewId,
- deskHelpOpen,
- selectedDocumentIds,
- selectedFolderIds,
- clearDocumentSelection,
- detailPanelOpen,
- handleDetailPanelClose,
- handleDeskDocumentOpen,
- inspectDocument,
- resolveThumbnailUrlForDoc,
- handleDocumentTagDrop,
- handleTagRemove,
- ensureAssetUrl,
- getDocumentAsset,
- activeTagFilters,
- handleDeleteSelection,
- tags,
- correspondents,
- documentLookup,
- tagLookupById,
- handleBulkTagAddFromDetail,
- handleBulkTagRemoveFromDetail,
- handleBulkCorrespondentAdd,
- handleBulkCorrespondentRemove,
- handleBulkSelectionReanalyze,
- folderOptions,
- moveDocumentsToFolder,
- searchIncludeDescendants,
- toggleSearchIncludeDescendants,
- ],
- );
-
- 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,
- previewWorkspaceEntry,
- previewDocumentId,
- closeDocumentPreview,
- handleThumbnailRegeneration,
- documentsTableProps,
- detailPanelProps,
- documentsViewMode,
- deskWorkspaceProps,
- ensurePreviewData,
- ensureAssetUrl,
- resolveApiPath,
- getDocumentAsset,
- notifyApiError,
- openTagsModal,
- openCorrespondentsModal,
- openSettings,
- detailPanelOpen,
- openDetailPanel,
- }),
- [
- 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,
- previewWorkspaceEntry,
- previewDocumentId,
- closeDocumentPreview,
- handleThumbnailRegeneration,
- documentsTableProps,
- detailPanelProps,
- documentsViewMode,
- deskWorkspaceProps,
- ensurePreviewData,
- ensureAssetUrl,
- getDocumentAsset,
- notifyApiError,
- openTagsModal,
- openCorrespondentsModal,
- openSettings,
- detailPanelOpen,
- openDetailPanel,
- ],
- );
-
- if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
- const shouldRememberLastLocation = appStatus !== 'logged-out';
- return (
-
- );
- }
-
- return (
-
-
-
-
- {managementModals}
-
-
- );
-};
-
-
-export default AppLayout;
+export { default } from './DocumentsAppLayout';
diff --git a/frontend/src/app/DocumentsAppLayout.jsx b/frontend/src/app/DocumentsAppLayout.jsx
new file mode 100644
index 0000000..05aece8
--- /dev/null
+++ b/frontend/src/app/DocumentsAppLayout.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { Navigate, Outlet } from 'react-router-dom';
+import { AppShellContext } from '../appShellContext';
+import DropOverlay from './DropOverlay';
+import useDocumentsWorkspace from './useDocumentsWorkspace';
+import { useDocumentsPreferences } from './useDocumentsPreferences';
+
+const DocumentsAppLayout = () => {
+ const documentsPreferences = useDocumentsPreferences();
+ const {
+ appStatus,
+ location,
+ shellRef,
+ dropOverlayState,
+ managementModals,
+ contextValue,
+ } = useDocumentsWorkspace({
+ documentsViewMode: documentsPreferences.documentsViewMode,
+ documentsSortField: documentsPreferences.documentsSortField,
+ documentsSortDirection: documentsPreferences.documentsSortDirection,
+ documentsSortFieldRef: documentsPreferences.documentsSortFieldRef,
+ documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef,
+ onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange,
+ onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange,
+ onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle,
+ searchIncludeDescendants: documentsPreferences.searchIncludeDescendants,
+ onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants,
+ onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants,
+ sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef,
+ deskHelpOpen: documentsPreferences.deskHelpOpen,
+ setDeskHelpOpen: documentsPreferences.setDeskHelpOpen,
+ handleDeskExit: documentsPreferences.handleDeskExit,
+ });
+
+ if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
+ const shouldRememberLastLocation = appStatus !== 'logged-out';
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {managementModals}
+
+
+ );
+};
+
+export default DocumentsAppLayout;
diff --git a/frontend/src/app/appLayoutUtils.js b/frontend/src/app/appLayoutUtils.js
new file mode 100644
index 0000000..1896b6a
--- /dev/null
+++ b/frontend/src/app/appLayoutUtils.js
@@ -0,0 +1,132 @@
+import { createAssetView } from '../asset_manager';
+
+export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
+export const DEFAULT_FOLDER_NAME = 'Documents';
+export const DEFAULT_SORT_FIELD = 'title';
+export const DEFAULT_SORT_DIRECTION = 'asc';
+export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at'];
+export const TAG_FILTER_UNTAGGED = '__UNTAGGED__';
+
+const ROW_KEY_SEPARATOR = ':';
+const DOCUMENT_ROW_PREFIX = 'document';
+const FOLDER_ROW_PREFIX = 'folder';
+
+export const resolveApiPath = (path = '') => path;
+
+const makeRowKey = (type, id) =>
+ id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
+
+const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
+
+export const getRowId = (key) => {
+ if (typeof key !== 'string') return '';
+ const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
+ if (separatorIndex === -1) return key;
+ return key.slice(separatorIndex + 1);
+};
+
+export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
+export const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX;
+
+export const resolveDocumentRowKey = (documentId) =>
+ documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null;
+
+export const resolveFolderRowKey = (folderId) =>
+ folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null;
+
+export const hasFiles = (event) =>
+ Array.from(event.dataTransfer?.types || []).includes('Files');
+
+const isAssetEquivalent = (lhs, rhs) => {
+ if (!lhs || !rhs) return false;
+ const lhsView = createAssetView(lhs);
+ const rhsView = createAssetView(rhs);
+ const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
+ const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
+ const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
+ const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
+ const lhsObjects = lhsView.getObjects();
+ const rhsObjects = rhsView.getObjects();
+ const objectsComparable =
+ lhsObjects.length === rhsObjects.length
+ && lhsObjects.every((entry, index) => {
+ const other = rhsObjects[index];
+ if (!other) return false;
+ if (entry.ordinal !== other.ordinal) return false;
+ if (entry.url && other.url && entry.url === other.url) {
+ return true;
+ }
+ if (!entry.url && !other.url) {
+ return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
+ }
+ return entry.url === other.url;
+ });
+ return (
+ lhs.id === rhs.id
+ && lhs.url === rhs.url
+ && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
+ && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
+ && lhs.mime_type === rhs.mime_type
+ && lhs.asset_type === rhs.asset_type
+ && lhs.updated_at === rhs.updated_at
+ && lhsCardinality === rhsCardinality
+ && objectsComparable
+ );
+};
+
+const mergeAssetIntoGroup = (group, assetData) => {
+ if (!assetData || !assetData.asset_type) {
+ if (Array.isArray(group)) {
+ return group;
+ }
+ return group || {};
+ }
+
+ if (Array.isArray(group) || !group) {
+ const list = Array.isArray(group) ? group : [];
+ const index = list.findIndex((item) => item?.id === assetData.id);
+ if (index >= 0) {
+ const existing = list[index];
+ if (isAssetEquivalent(existing, assetData)) {
+ return list;
+ }
+ const next = list.slice();
+ next[index] = { ...existing, ...assetData };
+ return next;
+ }
+ return list.concat({ ...assetData });
+ }
+
+ const key = assetData.asset_type;
+ const previous = group?.[key];
+ if (previous && isAssetEquivalent(previous, assetData)) {
+ return group;
+ }
+
+ const next = { ...(group || {}) };
+ next[key] = { ...(previous || {}), ...assetData };
+ return next;
+};
+
+export const mergeAssetIntoDocument = (doc, assetData) => {
+ if (!doc) return doc;
+ const existingGroup = doc.current_version?.assets || null;
+ const nextGroup = mergeAssetIntoGroup(existingGroup, assetData);
+ if (nextGroup === existingGroup) {
+ return doc;
+ }
+ const updatedCurrentVersion = doc.current_version
+ ? { ...doc.current_version, assets: nextGroup }
+ : { assets: nextGroup };
+ return { ...doc, current_version: updatedCurrentVersion };
+};
+
+export const createRootNode = () => ({
+ id: 'root',
+ name: DEFAULT_FOLDER_NAME,
+ parentId: null,
+ children: [],
+ expanded: true,
+ loaded: false,
+ hasChildren: false,
+});
diff --git a/frontend/src/app/appState.js b/frontend/src/app/appState.js
index a9b168a..01c5bf3 100644
--- a/frontend/src/app/appState.js
+++ b/frontend/src/app/appState.js
@@ -1,10 +1,5 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
-import axios from 'axios';
-
-const api = axios.create({
- baseURL: '/api',
- withCredentials: true,
-});
+import api from '../lib/api';
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
diff --git a/frontend/src/app/useDocumentPreview.js b/frontend/src/app/useDocumentPreview.js
new file mode 100644
index 0000000..4489ec3
--- /dev/null
+++ b/frontend/src/app/useDocumentPreview.js
@@ -0,0 +1,221 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const useDocumentPreview = ({
+ routeDocumentId,
+ documents,
+ searchResults,
+ setDocuments,
+ selectedFolder,
+ assetManager,
+ api,
+ resolveApiPath,
+ notifyApiError,
+ navigate,
+ locationPathname,
+ locationSearch,
+ detailPanelControlRef,
+ setActivePreviewId,
+}) => {
+ const [previewEntries, setPreviewEntries] = useState(() => new Map());
+ const previewInflightRef = useRef(new Map());
+ const previewReturnPathRef = useRef(null);
+
+ const resetPreviewState = useCallback(() => {
+ setPreviewEntries(() => new Map());
+ previewInflightRef.current = new Map();
+ previewReturnPathRef.current = null;
+ }, []);
+
+ const removePreviewEntries = useCallback((ids) => {
+ if (!Array.isArray(ids) || ids.length === 0) {
+ return;
+ }
+ setPreviewEntries((prev) => {
+ if (!prev.size) {
+ return prev;
+ }
+ let changed = false;
+ const next = new Map(prev);
+ ids.forEach((id) => {
+ if (next.delete(id)) {
+ changed = true;
+ }
+ previewInflightRef.current.delete(id);
+ });
+ return changed ? next : prev;
+ });
+ }, []);
+
+ const ensurePreviewUrl = useCallback(
+ async (documentId, { force = false } = {}) => {
+ if (!documentId) return null;
+
+ const existing = previewEntries.get(documentId) || null;
+ const now = Date.now();
+ const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
+ if (!force && existing && (!expiresAt || expiresAt > now)) {
+ return existing;
+ }
+
+ if (!force && previewInflightRef.current.has(documentId)) {
+ return previewInflightRef.current.get(documentId);
+ }
+
+ const request = (async () => {
+ try {
+ const docResponse = await api.get(`/documents/${documentId}`);
+ const downloadPath = docResponse.data?.document?.current_version?.download_path;
+ if (!downloadPath || !resolveApiPath) {
+ throw new Error('Document missing download path');
+ }
+
+ const href = resolveApiPath(downloadPath);
+ const entry = {
+ url: href,
+ contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
+ filename: docResponse.data?.document?.filename,
+ expiresAt: Date.now() + 5 * 60 * 1000,
+ };
+ setPreviewEntries((prev) => {
+ const next = new Map(prev);
+ next.set(documentId, entry);
+ return next;
+ });
+ return entry;
+ } catch (error) {
+ notifyApiError(error, 'Unable to fetch document preview.');
+ throw error;
+ } finally {
+ previewInflightRef.current.delete(documentId);
+ }
+ })();
+
+ previewInflightRef.current.set(documentId, request);
+ return request;
+ },
+ [previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
+ );
+
+ const ensurePreviewData = useCallback(
+ async (documentId) => {
+ if (!documentId) return null;
+
+ const findInCache = () => {
+ const pool = searchResults ?? documents;
+ return pool.find((item) => item.id === documentId) || null;
+ };
+
+ let doc = findInCache();
+
+ if (!doc) {
+ const { data } = await api.get(`/documents/${documentId}`);
+ const hydratedDetail = assetManager.hydrateDetail(data);
+ const fetched = hydratedDetail?.document || data.document || data;
+ doc = fetched ? assetManager.hydrateDocument(fetched) : null;
+ if (!doc) {
+ throw new Error('Document metadata unavailable.');
+ }
+
+ setDocuments((prev) => {
+ if (prev.some((item) => item.id === doc.id)) {
+ return prev;
+ }
+ return [doc, ...prev];
+ });
+ }
+
+ if (!previewReturnPathRef.current) {
+ const fallbackFolderId = doc?.folder_id || 'root';
+ previewReturnPathRef.current =
+ fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
+ }
+
+ await ensurePreviewUrl(documentId, { force: false });
+ setActivePreviewId(documentId);
+ return doc;
+ },
+ [
+ searchResults,
+ documents,
+ assetManager,
+ setDocuments,
+ ensurePreviewUrl,
+ setActivePreviewId,
+ api,
+ ],
+ );
+
+ const openDocumentPreview = useCallback(
+ (documentId, { replace = false } = {}) => {
+ if (!documentId) return;
+ detailPanelControlRef.current.close();
+ previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
+ navigate(`/documents/${documentId}`, { replace });
+ },
+ [navigate, locationPathname, locationSearch, detailPanelControlRef],
+ );
+
+ const closeDocumentPreview = useCallback(
+ (folderId = null) => {
+ const fallbackPath = previewReturnPathRef.current;
+ previewReturnPathRef.current = null;
+
+ if (fallbackPath) {
+ navigate(fallbackPath, { replace: false });
+ return;
+ }
+
+ const targetId = folderId || selectedFolder || 'root';
+ const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
+ navigate(path, { replace: false });
+ },
+ [navigate, selectedFolder],
+ );
+
+ useEffect(() => {
+ if (!routeDocumentId) {
+ return undefined;
+ }
+
+ const handleKeyDown = (event) => {
+ if (event.key === 'Escape') {
+ closeDocumentPreview();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [routeDocumentId, closeDocumentPreview]);
+
+ useEffect(() => {
+ if (!routeDocumentId) {
+ return undefined;
+ }
+
+ let cancelled = false;
+
+ ensurePreviewData(routeDocumentId).catch((error) => {
+ if (cancelled) {
+ return;
+ }
+ notifyApiError(error, 'Failed to open document preview.');
+ closeDocumentPreview();
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
+
+ return {
+ previewEntries,
+ ensurePreviewUrl,
+ ensurePreviewData,
+ openDocumentPreview,
+ closeDocumentPreview,
+ resetPreviewState,
+ removePreviewEntries,
+ };
+};
+
+export default useDocumentPreview;
diff --git a/frontend/src/app/useDocumentsPreferences.js b/frontend/src/app/useDocumentsPreferences.js
new file mode 100644
index 0000000..89a139a
--- /dev/null
+++ b/frontend/src/app/useDocumentsPreferences.js
@@ -0,0 +1,138 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ DEFAULT_SORT_DIRECTION,
+ DEFAULT_SORT_FIELD,
+ SORT_FIELD_VALUES,
+} from './appLayoutUtils';
+
+const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode';
+const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
+const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
+const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
+
+const readSessionStorage = (key) => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+ try {
+ return window.sessionStorage.getItem(key);
+ } catch (error) {
+ console.warn(`[session-storage] failed to read ${key}`, error);
+ return null;
+ }
+};
+
+const writeSessionStorage = (key, value) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.sessionStorage.setItem(key, value);
+ } catch (error) {
+ console.warn(`[session-storage] failed to persist ${key}`, error);
+ }
+};
+
+export const useDocumentsPreferences = () => {
+ const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
+ const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
+ if (stored === 'grid' || stored === 'desk') {
+ return stored;
+ }
+ return 'list';
+ });
+
+ const [deskHelpOpen, setDeskHelpOpen] = useState(false);
+ const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
+
+ useEffect(() => {
+ if (documentsViewMode !== 'desk') {
+ lastNonDeskViewRef.current = documentsViewMode;
+ } else if (deskHelpOpen) {
+ setDeskHelpOpen(false);
+ }
+ }, [documentsViewMode, deskHelpOpen]);
+
+ const setDocumentsViewMode = useCallback((mode) => {
+ const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
+ setDocumentsViewModeState((previous) => {
+ if (next !== previous) {
+ writeSessionStorage(VIEW_MODE_STORAGE_KEY, next);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleDeskExit = useCallback(() => {
+ const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
+ ? lastNonDeskViewRef.current
+ : 'list';
+ setDocumentsViewMode(fallback);
+ }, [setDocumentsViewMode]);
+
+ const [documentsSortField, setDocumentsSortField] = useState(() => {
+ const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY);
+ return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD;
+ });
+ const documentsSortFieldRef = useRef(documentsSortField);
+ useEffect(() => {
+ documentsSortFieldRef.current = documentsSortField;
+ writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField);
+ }, [documentsSortField]);
+
+ const [documentsSortDirection, setDocumentsSortDirection] = useState(() => {
+ const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY);
+ return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION;
+ });
+ const documentsSortDirectionRef = useRef(documentsSortDirection);
+ useEffect(() => {
+ documentsSortDirectionRef.current = documentsSortDirection;
+ writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
+ }, [documentsSortDirection]);
+
+ const handleDocumentsSortFieldChange = useCallback((field) => {
+ const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
+ setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
+ }, []);
+
+ const handleDocumentsSortDirectionToggle = useCallback(() => {
+ setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc'));
+ }, []);
+
+ const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => {
+ const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY);
+ if (stored === 'true') return true;
+ if (stored === 'false') return false;
+ return true;
+ });
+ useEffect(() => {
+ writeSessionStorage(
+ INCLUDE_DESCENDANTS_STORAGE_KEY,
+ searchIncludeDescendants ? 'true' : 'false',
+ );
+ }, [searchIncludeDescendants]);
+
+ const toggleSearchIncludeDescendants = useCallback(() => {
+ setSearchIncludeDescendants((previous) => !previous);
+ }, []);
+
+ const sortRefreshReadyRef = useRef(false);
+
+ return {
+ documentsViewMode,
+ handleDocumentsViewModeChange: setDocumentsViewMode,
+ handleDeskExit,
+ deskHelpOpen,
+ setDeskHelpOpen,
+ documentsSortField,
+ documentsSortDirection,
+ documentsSortFieldRef,
+ documentsSortDirectionRef,
+ handleDocumentsSortFieldChange,
+ handleDocumentsSortDirectionToggle,
+ searchIncludeDescendants,
+ setSearchIncludeDescendants,
+ toggleSearchIncludeDescendants,
+ sortRefreshReadyRef,
+ };
+};
diff --git a/frontend/src/app/useDocumentsSearch.js b/frontend/src/app/useDocumentsSearch.js
new file mode 100644
index 0000000..e5e0447
--- /dev/null
+++ b/frontend/src/app/useDocumentsSearch.js
@@ -0,0 +1,238 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ TAG_FILTER_UNTAGGED,
+ resolveDocumentRowKey,
+ isDocumentRowKey,
+} from './appLayoutUtils';
+
+const useDocumentsSearch = ({
+ api,
+ assetManager,
+ token,
+ selectedFolder,
+ navigate,
+ locationPathname,
+ isDocumentsRoute,
+ selectionHelpers,
+ searchIncludeDescendants,
+ documentsSortField,
+ documentsSortDirection,
+ notifyApiError,
+ setLoading,
+ setSearchIncludeDescendants,
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeTagFilters, setActiveTagFilters] = useState([]);
+ const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
+ const [searchResults, setSearchResults] = useState(null);
+ const [searchLoading, setSearchLoading] = useState(false);
+
+ const toggleTagFilter = useCallback((tagId) => {
+ if (!tagId) return;
+ setActiveTagFilters((previous) => {
+ if (tagId === TAG_FILTER_UNTAGGED) {
+ return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
+ }
+ const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED);
+ if (sanitized.includes(tagId)) {
+ return sanitized.filter((id) => id !== tagId);
+ }
+ return sanitized.concat([tagId]);
+ });
+ }, []);
+
+ const toggleCorrespondentFilter = useCallback((correspondentId) => {
+ setActiveCorrespondentFilters((previous) => {
+ if (!correspondentId) {
+ return [];
+ }
+ return previous.includes(correspondentId) ? [] : [correspondentId];
+ });
+ }, []);
+
+ const isFilterActive = useMemo(
+ () =>
+ searchQuery.trim().length > 0
+ || activeTagFilters.length > 0
+ || activeCorrespondentFilters.length > 0,
+ [searchQuery, activeTagFilters, activeCorrespondentFilters],
+ );
+
+ const clearFilters = useCallback(() => {
+ setSearchQuery('');
+ setActiveTagFilters([]);
+ setActiveCorrespondentFilters([]);
+ setSearchLoading(false);
+ setSearchIncludeDescendants(true);
+ }, [
+ setSearchIncludeDescendants,
+ ]);
+
+ const handleSearchChange = useCallback((value) => {
+ setSearchQuery(value);
+ }, []);
+
+ const handleSearchSubmit = useCallback(() => {
+ if (!navigate) return;
+ const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
+ const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
+ if (!isDocumentsRoute || locationPathname !== targetPath) {
+ navigate(targetPath, { replace: false });
+ }
+ }, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
+
+ useEffect(() => {
+ if (!token) return undefined;
+
+ if (!isFilterActive) {
+ setSearchResults(null);
+ setSearchLoading(false);
+ return undefined;
+ }
+
+ let cancelled = false;
+ let started = false;
+ setSearchLoading(true);
+
+ const debounce = setTimeout(async () => {
+ started = true;
+ setLoading(true);
+ try {
+ const params = {};
+ const trimmedQuery = searchQuery.trim();
+ if (trimmedQuery.length) {
+ params.query = trimmedQuery;
+ }
+ if (activeTagFilters.length) {
+ const onlyUntagged = activeTagFilters.length === 1
+ && activeTagFilters[0] === TAG_FILTER_UNTAGGED;
+ if (onlyUntagged) {
+ params.tags = 'none';
+ } else {
+ const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED);
+ if (tagIds.length) {
+ params.tags = tagIds.join(',');
+ }
+ }
+ }
+ if (activeCorrespondentFilters.length) {
+ params.correspondents = activeCorrespondentFilters.join(',');
+ }
+ const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
+ if (folderIdentifier) {
+ params.folder_id = folderIdentifier;
+ }
+ if (!searchIncludeDescendants) {
+ params.include_descendants = false;
+ }
+ if (documentsSortField) {
+ params.sort = documentsSortField;
+ }
+ if (documentsSortDirection) {
+ params.dir = documentsSortDirection;
+ }
+ const { data } = await api.get('/documents', { params });
+ if (cancelled) return;
+
+ const results = assetManager.hydrateDocuments(data || []);
+ setSearchResults(results);
+
+ if (!results.length) {
+ setSearchLoading(false);
+ selectionHelpers.setSelectedEntries([]);
+ selectionHelpers.setFocusedDocumentId(null);
+ selectionHelpers.selectionOrderRef.current = [];
+ selectionHelpers.setSelectionOrder([]);
+ selectionHelpers.selectionAnchorRef.current = null;
+ return;
+ }
+
+ const resultKeys = results
+ .map((doc) => resolveDocumentRowKey(doc.id))
+ .filter(Boolean);
+
+ let targetKey = null;
+ let nextSelectionKeys = [];
+
+ selectionHelpers.setSelectedEntries((previous) => {
+ const previousDocKeys = previous.filter(isDocumentRowKey);
+ const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
+ if (filtered.length) {
+ targetKey = filtered[filtered.length - 1];
+ nextSelectionKeys = filtered;
+ return filtered;
+ }
+ targetKey = null;
+ nextSelectionKeys = [];
+ return [];
+ });
+
+ selectionHelpers.selectionOrderRef.current = nextSelectionKeys;
+ selectionHelpers.setSelectionOrder(nextSelectionKeys);
+
+ selectionHelpers.setFocusedDocumentId((previous) => {
+ if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
+ return previous;
+ }
+ return null;
+ });
+
+ selectionHelpers.selectionAnchorRef.current = targetKey;
+ } catch (error) {
+ if (cancelled) return;
+ notifyApiError(error, 'Search failed. Please try again.');
+ setSearchResults(null);
+ } finally {
+ if (!cancelled && started) {
+ setLoading(false);
+ setSearchLoading(false);
+ }
+ }
+ }, 300);
+
+ return () => {
+ cancelled = true;
+ clearTimeout(debounce);
+ if (started) {
+ setLoading(false);
+ setSearchLoading(false);
+ }
+ };
+ }, [
+ api,
+ token,
+ isFilterActive,
+ searchQuery,
+ activeTagFilters,
+ activeCorrespondentFilters,
+ searchIncludeDescendants,
+ documentsSortField,
+ documentsSortDirection,
+ selectedFolder,
+ notifyApiError,
+ assetManager,
+ selectionHelpers,
+ setLoading,
+ ]);
+
+ return {
+ searchQuery,
+ setSearchQuery,
+ searchResults,
+ setSearchResults,
+ searchLoading,
+ setSearchLoading,
+ activeTagFilters,
+ setActiveTagFilters,
+ activeCorrespondentFilters,
+ setActiveCorrespondentFilters,
+ toggleTagFilter,
+ toggleCorrespondentFilter,
+ isFilterActive,
+ clearFilters,
+ handleSearchChange,
+ handleSearchSubmit,
+ };
+};
+
+export default useDocumentsSearch;
diff --git a/frontend/src/app/useDocumentsWorkspace.js b/frontend/src/app/useDocumentsWorkspace.js
new file mode 100644
index 0000000..d3bf8a1
--- /dev/null
+++ b/frontend/src/app/useDocumentsWorkspace.js
@@ -0,0 +1 @@
+export { default } from '../hooks/documents/useDocumentsWorkspace';
diff --git a/frontend/src/desktop/useDeskWorkspaceProps.js b/frontend/src/desktop/useDeskWorkspaceProps.js
new file mode 100644
index 0000000..6d22c1f
--- /dev/null
+++ b/frontend/src/desktop/useDeskWorkspaceProps.js
@@ -0,0 +1,141 @@
+import { useMemo } from 'react';
+
+const useDeskWorkspaceProps = ({
+ documents,
+ searchResults,
+ breadcrumbs,
+ currentFolderName,
+ documentsViewMode,
+ handleDocumentsViewModeChange,
+ handleDeskExit,
+ refreshCurrentFolder,
+ handleDeskDocumentOpen,
+ inspectDocument,
+ handleEntryPointerCore,
+ handleDeskDocumentStackSelect,
+ promoteSelectionOrder,
+ handleDeskHelpOpen,
+ deskHelpOpen,
+ handleDeskHelpClose,
+ currentTenantId,
+ deskViewId,
+ selectedDocumentIds,
+ selectedFolderIds,
+ clearDocumentSelection,
+ detailPanelOpen,
+ handleDetailPanelClose,
+ resolveThumbnailUrlForDoc,
+ handleDocumentTagDrop,
+ handleTagRemove,
+ ensureAssetUrl,
+ getDocumentAsset,
+ activeTagFilters,
+ handleDeleteSelection,
+ tags,
+ correspondents,
+ documentLookup,
+ tagLookupById,
+ handleBulkTagAddFromDetail,
+ handleBulkTagRemoveFromDetail,
+ handleBulkCorrespondentAdd,
+ handleBulkCorrespondentRemove,
+ handleBulkSelectionReanalyze,
+ folderOptions,
+ moveDocumentsToFolder,
+ searchIncludeDescendants,
+ toggleSearchIncludeDescendants,
+}) =>
+ useMemo(
+ () => ({
+ documents,
+ searchResults,
+ breadcrumbs,
+ currentFolderName,
+ viewMode: documentsViewMode,
+ onViewModeChange: handleDocumentsViewModeChange,
+ onExit: handleDeskExit,
+ onRefresh: refreshCurrentFolder,
+ onDocumentOpen: handleDeskDocumentOpen,
+ onInspectDocument: inspectDocument,
+ onEntryPointer: handleEntryPointerCore,
+ onDocumentStackSelect: handleDeskDocumentStackSelect,
+ onPromoteSelection: promoteSelectionOrder,
+ onOpenHelp: handleDeskHelpOpen,
+ helpOpen: deskHelpOpen,
+ onHelpClose: handleDeskHelpClose,
+ tenantId: currentTenantId,
+ viewId: deskViewId,
+ selectedDocumentIds,
+ selectedFolderIds,
+ onClearSelection: clearDocumentSelection,
+ detailPanelOpen,
+ onCloseDetailPanel: handleDetailPanelClose,
+ resolveThumbnailUrl: resolveThumbnailUrlForDoc,
+ onAssignTagToDocument: handleDocumentTagDrop,
+ onRemoveTagFromDocument: handleTagRemove,
+ ensureAssetUrl,
+ getDocumentAsset,
+ activeTagIds: activeTagFilters,
+ onDeleteSelection: handleDeleteSelection,
+ tags,
+ correspondents,
+ documentLookup,
+ tagLookupById,
+ onBulkTagAdd: handleBulkTagAddFromDetail,
+ onBulkTagRemove: handleBulkTagRemoveFromDetail,
+ onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
+ onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
+ onBulkReanalyze: handleBulkSelectionReanalyze,
+ folderOptions,
+ onMoveDocumentsToFolder: moveDocumentsToFolder,
+ searchIncludeDescendants,
+ onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
+ }),
+ [
+ documents,
+ searchResults,
+ breadcrumbs,
+ currentFolderName,
+ documentsViewMode,
+ handleDocumentsViewModeChange,
+ handleDeskExit,
+ refreshCurrentFolder,
+ handleDeskDocumentOpen,
+ inspectDocument,
+ handleEntryPointerCore,
+ handleDeskDocumentStackSelect,
+ promoteSelectionOrder,
+ handleDeskHelpOpen,
+ deskHelpOpen,
+ handleDeskHelpClose,
+ currentTenantId,
+ deskViewId,
+ selectedDocumentIds,
+ selectedFolderIds,
+ clearDocumentSelection,
+ detailPanelOpen,
+ handleDetailPanelClose,
+ resolveThumbnailUrlForDoc,
+ handleDocumentTagDrop,
+ handleTagRemove,
+ ensureAssetUrl,
+ getDocumentAsset,
+ activeTagFilters,
+ handleDeleteSelection,
+ tags,
+ correspondents,
+ documentLookup,
+ tagLookupById,
+ handleBulkTagAddFromDetail,
+ handleBulkTagRemoveFromDetail,
+ handleBulkCorrespondentAdd,
+ handleBulkCorrespondentRemove,
+ handleBulkSelectionReanalyze,
+ folderOptions,
+ moveDocumentsToFolder,
+ searchIncludeDescendants,
+ toggleSearchIncludeDescendants,
+ ],
+ );
+
+export default useDeskWorkspaceProps;
diff --git a/frontend/src/hooks/documents/store/useDocumentsStore.js b/frontend/src/hooks/documents/store/useDocumentsStore.js
new file mode 100644
index 0000000..08cac6e
--- /dev/null
+++ b/frontend/src/hooks/documents/store/useDocumentsStore.js
@@ -0,0 +1,13 @@
+import { useCallback, useState } from 'react';
+
+export const useDocumentsStore = () => {
+ const [status, setStatus] = useState(null);
+
+ const setStatusMessage = useCallback((message, variant = 'info') => {
+ setStatus(message ? { message, variant } : null);
+ }, []);
+
+ return { status, setStatusMessage };
+};
+
+export default useDocumentsStore;
diff --git a/frontend/src/hooks/documents/useAuthManager.js b/frontend/src/hooks/documents/useAuthManager.js
new file mode 100644
index 0000000..63b5f19
--- /dev/null
+++ b/frontend/src/hooks/documents/useAuthManager.js
@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+const useAuthManager = ({
+ apiClient,
+ token,
+ appStatus,
+ appDispatch,
+ notifyApiError,
+ setStatusMessage,
+ setLoading,
+}) => {
+ const tokenRef = useRef(token);
+ const refreshPromiseRef = useRef(null);
+ const initialRefreshAttemptedRef = useRef(Boolean(token));
+
+ const refreshAccessToken = useCallback(async () => {
+ console.log('[Auth] Attempting to refresh access token…');
+ appDispatch({ type: 'TOKEN_REFRESH_START' });
+ try {
+ const { data } = await apiClient.post('/auth/refresh');
+ if (data?.access_token) {
+ appDispatch({
+ type: 'TOKEN_REFRESH_SUCCESS',
+ token: data.access_token,
+ tenant: data.tenant || null,
+ });
+ console.log('[Auth] Access token refreshed at', new Date().toISOString());
+ return data.access_token;
+ }
+ throw new Error('Missing access token in refresh response');
+ } catch (error) {
+ console.warn('[Auth] Failed to refresh access token', error);
+ appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
+ throw error;
+ }
+ }, [apiClient, appDispatch]);
+
+ useEffect(() => {
+ tokenRef.current = token;
+ }, [token]);
+
+ useEffect(() => {
+ if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
+ initialRefreshAttemptedRef.current = true;
+ console.log('[Auth] Attempting refresh at startup');
+ refreshAccessToken().catch(() => {});
+ }
+ }, [token, appStatus, refreshAccessToken]);
+
+ useEffect(() => {
+ const requestInterceptor = apiClient.interceptors.request.use((config) => {
+ const currentToken = tokenRef.current;
+ if (currentToken) {
+ config.headers = config.headers || {};
+ if (!config.headers.Authorization) {
+ config.headers.Authorization = `Bearer ${currentToken}`;
+ }
+ }
+ return config;
+ });
+
+ const responseInterceptor = apiClient.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const { response, config } = error;
+ if (!response || !config) {
+ return Promise.reject(error);
+ }
+
+ const status = response.status;
+ const url = typeof config.url === 'string' ? config.url : '';
+ const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
+
+ if (status === 401 && !config._retry && !isAuthRoute) {
+ console.warn('[Auth] 401 received for', url, '- attempting token refresh');
+
+ if (!refreshPromiseRef.current) {
+ refreshPromiseRef.current = (async () => {
+ try {
+ return await refreshAccessToken();
+ } finally {
+ refreshPromiseRef.current = null;
+ }
+ })();
+ }
+
+ try {
+ const newToken = await refreshPromiseRef.current;
+ if (!newToken) {
+ throw new Error('No token returned from refresh');
+ }
+ config._retry = true;
+ config.headers = config.headers || {};
+ config.headers.Authorization = `Bearer ${newToken}`;
+ console.log('[Auth] Retrying original request', url);
+ try {
+ return await apiClient(config);
+ } catch (retryError) {
+ if (retryError?.response?.status === 401) {
+ notifyApiError(retryError, 'Session expired. Please log in again.');
+ }
+ throw retryError;
+ }
+ } catch (refreshError) {
+ console.warn('[Auth] Refresh failed, clearing session');
+ notifyApiError(refreshError, 'Session expired. Please log in again.');
+ return Promise.reject(refreshError);
+ }
+ }
+
+ return Promise.reject(error);
+ },
+ );
+
+ return () => {
+ apiClient.interceptors.request.eject(requestInterceptor);
+ apiClient.interceptors.response.eject(responseInterceptor);
+ };
+ }, [apiClient, notifyApiError, refreshAccessToken]);
+
+ const handleLogout = useCallback(async () => {
+ try {
+ setLoading(true);
+ await apiClient.post('/auth/logout');
+ } catch (error) {
+ console.warn('[Auth] Failed to revoke refresh token during logout', error);
+ } finally {
+ setLoading(false);
+ appDispatch({ type: 'LOGOUT' });
+ setStatusMessage('Logged out.', 'info');
+ }
+ }, [apiClient, appDispatch, setLoading, setStatusMessage]);
+
+ return { tokenRef, refreshAccessToken, handleLogout };
+};
+
+export default useAuthManager;
diff --git a/frontend/src/hooks/documents/useCorrespondents.js b/frontend/src/hooks/documents/useCorrespondents.js
new file mode 100644
index 0000000..cc6c281
--- /dev/null
+++ b/frontend/src/hooks/documents/useCorrespondents.js
@@ -0,0 +1,127 @@
+import { useCallback, useState } from 'react';
+
+const useCorrespondents = ({
+ apiClient,
+ notifyApiError,
+ setStatusMessage,
+ tenantIdRef,
+ mapDocumentCaches,
+}) => {
+ const [correspondents, setCorrespondents] = useState([]);
+
+ const refreshCorrespondents = useCallback(async () => {
+ const requestTenantId = tenantIdRef.current;
+ try {
+ const { data } = await apiClient.get('/correspondents');
+ if (tenantIdRef.current !== requestTenantId) {
+ return;
+ }
+ setCorrespondents(data || []);
+ } catch (error) {
+ if (tenantIdRef.current !== requestTenantId) {
+ return;
+ }
+ notifyApiError(error, 'Unable to load correspondents.');
+ }
+ }, [apiClient, notifyApiError, tenantIdRef]);
+
+ const handleCorrespondentUpdate = useCallback(
+ async (correspondentId, changes) => {
+ if (!correspondentId) {
+ throw new Error('Missing correspondent identifier.');
+ }
+
+ const payload = {};
+ if (typeof changes.name === 'string') {
+ const trimmed = changes.name.trim();
+ if (!trimmed) {
+ throw new Error('Correspondent name cannot be empty.');
+ }
+ payload.name = trimmed;
+ }
+
+ if (Object.keys(payload).length === 0) {
+ return false;
+ }
+
+ try {
+ await apiClient.patch(`/correspondents/${correspondentId}`, payload);
+ await refreshCorrespondents();
+ setStatusMessage('Correspondent updated.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to update correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, notifyApiError, refreshCorrespondents, setStatusMessage],
+ );
+
+ const handleCorrespondentCreate = useCallback(
+ async ({ name }) => {
+ const trimmed = typeof name === 'string' ? name.trim() : '';
+ if (!trimmed) {
+ throw new Error('Correspondent name is required.');
+ }
+ try {
+ const { data } = await apiClient.post('/correspondents', { name: trimmed });
+ await refreshCorrespondents();
+ setStatusMessage('Correspondent created.', 'success');
+ return data;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to create correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, notifyApiError, refreshCorrespondents, setStatusMessage],
+ );
+
+ const handleCorrespondentDelete = useCallback(
+ async (correspondentId) => {
+ if (!correspondentId) {
+ throw new Error('Missing correspondent identifier.');
+ }
+
+ const stripFromDoc = (doc) => {
+ if (!doc || !Array.isArray(doc.correspondents)) {
+ return doc;
+ }
+ const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
+ if (next.length === doc.correspondents.length) {
+ return doc;
+ }
+ return { ...doc, correspondents: next };
+ };
+
+ try {
+ await apiClient.delete(`/correspondents/${correspondentId}`);
+ await refreshCorrespondents();
+
+ if (typeof mapDocumentCaches === 'function') {
+ mapDocumentCaches(stripFromDoc);
+ }
+
+ setStatusMessage('Correspondent deleted.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to delete correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, mapDocumentCaches, notifyApiError, refreshCorrespondents, setStatusMessage],
+ );
+
+ return {
+ correspondents,
+ refreshCorrespondents,
+ handleCorrespondentCreate,
+ handleCorrespondentUpdate,
+ handleCorrespondentDelete,
+ setCorrespondents,
+ };
+};
+
+export default useCorrespondents;
diff --git a/frontend/src/hooks/documents/useDocumentCorrespondentActions.js b/frontend/src/hooks/documents/useDocumentCorrespondentActions.js
new file mode 100644
index 0000000..8402795
--- /dev/null
+++ b/frontend/src/hooks/documents/useDocumentCorrespondentActions.js
@@ -0,0 +1,130 @@
+import { useCallback, useMemo } from 'react';
+
+const useDocumentCorrespondentActions = ({
+ apiClient,
+ correspondents,
+ handleCorrespondentCreate,
+ refreshCurrentFolder,
+ notifyApiError,
+ setStatusMessage,
+}) => {
+ const correspondentLookupByName = useMemo(() => {
+ const map = new Map();
+ correspondents.forEach((correspondent) => {
+ if (correspondent?.name) {
+ map.set(correspondent.name.toLowerCase(), correspondent);
+ }
+ });
+ return map;
+ }, [correspondents]);
+
+ const handleDocumentCorrespondentAttach = useCallback(
+ async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
+ if (!documentId || !correspondentId) {
+ throw new Error('Missing document or correspondent.');
+ }
+ try {
+ await apiClient.post(`/documents/${documentId}/correspondents`, {
+ assignments: [{ correspondent_id: correspondentId }],
+ replace: false,
+ });
+ if (refresh) {
+ await refreshCurrentFolder();
+ }
+ if (notify) {
+ setStatusMessage('Correspondent assigned.', 'success');
+ }
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to assign correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
+ );
+
+ const handleCorrespondentRemove = useCallback(
+ async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
+ if (!documentId || !correspondentId) {
+ throw new Error('Missing document or correspondent.');
+ }
+ try {
+ await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
+ if (refresh) {
+ await refreshCurrentFolder();
+ }
+ if (notify) {
+ setStatusMessage('Correspondent removed.', 'success');
+ }
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to remove correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
+ );
+
+ const handleCorrespondentAdd = useCallback(
+ async ({ document, name, input = null, option = null }) => {
+ if (!document?.id) {
+ throw new Error('Missing document for correspondent assignment.');
+ }
+ const trimmed = typeof name === 'string' ? name.trim() : '';
+ if (!trimmed) {
+ setStatusMessage('Correspondent name is required.', 'error');
+ return;
+ }
+
+ let target = null;
+ if (option && option.id) {
+ target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
+ } else {
+ target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
+ }
+ if (!target) {
+ try {
+ target = await handleCorrespondentCreate({ name: trimmed });
+ } catch (error) {
+ return;
+ }
+ }
+
+ if (!target?.id) {
+ setStatusMessage('Unable to resolve correspondent.', 'error');
+ return;
+ }
+
+ try {
+ await handleDocumentCorrespondentAttach({
+ documentId: document.id,
+ correspondentId: target.id,
+ });
+ if (input) {
+ input.value = '';
+ }
+ } catch (error) {
+ setStatusMessage('Failed to assign correspondent.', 'error');
+ console.error('[documents] assign correspondent failed', error);
+ }
+ },
+ [
+ correspondentLookupByName,
+ handleCorrespondentCreate,
+ handleDocumentCorrespondentAttach,
+ setStatusMessage,
+ ],
+ );
+
+ return {
+ correspondentLookupByName,
+ handleDocumentCorrespondentAttach,
+ handleCorrespondentRemove,
+ handleCorrespondentAdd,
+ };
+};
+
+export default useDocumentCorrespondentActions;
+
diff --git a/frontend/src/hooks/documents/useDocumentDragHandlers.js b/frontend/src/hooks/documents/useDocumentDragHandlers.js
new file mode 100644
index 0000000..3a09fee
--- /dev/null
+++ b/frontend/src/hooks/documents/useDocumentDragHandlers.js
@@ -0,0 +1,252 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+const useDocumentDragHandlers = ({
+ selectedEntries,
+ selectedDocumentIds,
+ selectedFolderIds,
+ applySelection,
+ handleEntrySelection,
+ documentLookup,
+ setDraggedDocumentIds,
+ setDraggedFolderId,
+ resolveDocumentRowKey,
+ resolveFolderRowKey,
+ documentsViewMode,
+}) => {
+ const dragPreviewRef = useRef(null);
+
+ const destroyDragPreview = useCallback(() => {
+ const node = dragPreviewRef.current;
+ if (node && node.parentNode) {
+ node.parentNode.removeChild(node);
+ }
+ dragPreviewRef.current = null;
+ }, []);
+
+ useEffect(() => destroyDragPreview, [destroyDragPreview]);
+
+ const createDragPreview = useCallback(
+ ({ documents = [], folders = [] } = {}) => {
+ destroyDragPreview();
+
+ const docEntries = (documents || []).filter(Boolean);
+ const folderEntries = (folders || []).filter(Boolean);
+ const totalCount = docEntries.length + folderEntries.length;
+ if (!totalCount) {
+ return null;
+ }
+
+ const maxVisible = 4;
+ const size = 64;
+ const canvasSize = Math.round(size * 1.6);
+
+ const visibleItems = [];
+ docEntries.slice(0, maxVisible).forEach((doc) => {
+ visibleItems.push({ type: 'document', payload: doc });
+ });
+
+ if (visibleItems.length < maxVisible) {
+ folderEntries
+ .slice(0, maxVisible - visibleItems.length)
+ .forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId }));
+ }
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'document-drag-preview';
+ wrapper.style.width = `${canvasSize}px`;
+ wrapper.style.height = `${canvasSize}px`;
+
+ visibleItems.forEach((item, index) => {
+ const slot = document.createElement('div');
+ slot.className = 'document-drag-preview__item';
+ slot.style.setProperty('--index', String(index));
+ slot.style.width = `${size}px`;
+ slot.style.height = `${size}px`;
+
+ if (item.type === 'document') {
+ slot.textContent = item.payload?.title || 'Document';
+ } else {
+ slot.textContent = 'Folder';
+ }
+ wrapper.appendChild(slot);
+ });
+
+ document.body.appendChild(wrapper);
+ dragPreviewRef.current = wrapper;
+ return wrapper;
+ },
+ [destroyDragPreview],
+ );
+
+ const handleDocumentDragStart = useCallback(
+ (event, documentOrId) => {
+ const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
+ if (!documentId) {
+ return;
+ }
+
+ const documentKey = resolveDocumentRowKey(documentId);
+ if (!documentKey) {
+ return;
+ }
+
+ const isGridView = documentsViewMode === 'grid';
+ const isAlreadySelected = selectedDocumentIds.includes(documentId);
+ const selection = isAlreadySelected
+ ? [...selectedDocumentIds]
+ : isGridView
+ ? [...selectedDocumentIds, documentId]
+ : [documentId];
+ const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : [];
+
+ if (!isAlreadySelected && !isGridView) {
+ applySelection([documentKey], {
+ anchor: documentKey,
+ interactedKeys: [documentKey],
+ });
+ }
+
+ const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
+ const previewNode = createDragPreview({
+ documents: previewDocs,
+ folders: folderSelection,
+ });
+
+ setDraggedDocumentIds(selection);
+ if (folderSelection.length) {
+ setDraggedFolderId(folderSelection[0] || null);
+ }
+ event.dataTransfer.effectAllowed = 'move';
+ try {
+ event.dataTransfer.setData(
+ 'application/x-papercrate-doc-list',
+ JSON.stringify(selection),
+ );
+ if (folderSelection.length) {
+ event.dataTransfer.setData(
+ 'application/x-papercrate-folder-list',
+ JSON.stringify(folderSelection),
+ );
+ if (folderSelection.length === 1) {
+ event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]);
+ }
+ }
+ } catch (error) {
+ console.warn('[documents] Failed to populate drag payload', error);
+ }
+ if (previewNode) {
+ const width = previewNode.offsetWidth || 96;
+ const height = previewNode.offsetHeight || 96;
+ event.dataTransfer.setDragImage(previewNode, width / 2, height / 2);
+ }
+ event.currentTarget.classList.add('dragging');
+ },
+ [
+ selectedDocumentIds,
+ selectedFolderIds,
+ applySelection,
+ documentLookup,
+ createDragPreview,
+ setDraggedFolderId,
+ setDraggedDocumentIds,
+ documentsViewMode,
+ resolveDocumentRowKey,
+ ],
+ );
+
+ const handleDocumentDragEnd = useCallback(
+ (event) => {
+ setDraggedDocumentIds([]);
+ event.currentTarget.classList.remove('dragging');
+ destroyDragPreview();
+ setDraggedFolderId(null);
+ },
+ [destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds],
+ );
+
+ const handleFolderDragStart = useCallback(
+ (event, folderId) => {
+ if (folderId === 'root') {
+ return;
+ }
+ event.stopPropagation();
+ const folderKey = resolveFolderRowKey(folderId);
+ const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
+
+ let effectiveFolderSelection = selectedFolderIds;
+ let effectiveDocumentSelection = selectedDocumentIds;
+
+ if (!isAlreadySelected && folderKey) {
+ effectiveFolderSelection = [folderId];
+ effectiveDocumentSelection = [];
+ handleEntrySelection(folderKey, { preventDefault: () => {} });
+ }
+
+ const uniqueFolders = effectiveFolderSelection.length
+ ? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
+ : [folderId];
+
+ setDraggedFolderId(folderId);
+ if (effectiveDocumentSelection.length) {
+ setDraggedDocumentIds(effectiveDocumentSelection);
+ }
+
+ event.dataTransfer.effectAllowed = 'move';
+ try {
+ event.dataTransfer.setData(
+ 'application/x-papercrate-folder-list',
+ JSON.stringify(uniqueFolders),
+ );
+ if (uniqueFolders.length === 1) {
+ event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]);
+ }
+ if (effectiveDocumentSelection.length) {
+ event.dataTransfer.setData(
+ 'application/x-papercrate-doc-list',
+ JSON.stringify(effectiveDocumentSelection),
+ );
+ }
+ } catch (error) {
+ console.warn('[documents] Failed to populate folder drag payload', error);
+ }
+
+ const previewDocs = effectiveDocumentSelection
+ .map((id) => documentLookup.get(id) || null)
+ .filter(Boolean);
+ createDragPreview({ documents: previewDocs, folders: uniqueFolders });
+ event.currentTarget.classList.add('dragging');
+ },
+ [
+ selectedFolderIds,
+ selectedEntries,
+ selectedDocumentIds,
+ handleEntrySelection,
+ setDraggedFolderId,
+ setDraggedDocumentIds,
+ documentLookup,
+ createDragPreview,
+ resolveFolderRowKey,
+ ],
+ );
+
+ const handleFolderDragEnd = useCallback(
+ (event) => {
+ if (event?.currentTarget) {
+ event.currentTarget.classList.remove('dragging');
+ }
+ setDraggedFolderId(null);
+ setDraggedDocumentIds([]);
+ destroyDragPreview();
+ },
+ [setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview],
+ );
+
+ return {
+ handleDocumentDragStart,
+ handleDocumentDragEnd,
+ handleFolderDragStart,
+ handleFolderDragEnd,
+ };
+};
+
+export default useDocumentDragHandlers;
diff --git a/frontend/src/hooks/documents/useDocumentTagging.js b/frontend/src/hooks/documents/useDocumentTagging.js
new file mode 100644
index 0000000..6347ec3
--- /dev/null
+++ b/frontend/src/hooks/documents/useDocumentTagging.js
@@ -0,0 +1,204 @@
+import { useCallback } from 'react';
+
+const useDocumentTagging = ({
+ apiClient,
+ tags,
+ tagManager,
+ refreshTags,
+ refreshCurrentFolder,
+ resolveTargetDocumentIds,
+ notifyApiError,
+ setStatusMessage,
+ setLoading,
+}) => {
+ const bulkTagOperation = useCallback(
+ async ({ labels, action, documentIds }) => {
+ const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
+ if (!normalized.length) {
+ return { ok: false, reason: 'no-labels' };
+ }
+ const targetDocumentIds = resolveTargetDocumentIds(documentIds);
+ if (!targetDocumentIds.length) {
+ return { ok: false, reason: 'no-selection' };
+ }
+
+ let tagIds = [];
+
+ if (action === 'remove') {
+ const missing = normalized.find(
+ (label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
+ );
+ if (missing) {
+ return { ok: false, reason: 'tag-missing', label: missing };
+ }
+
+ tagIds = normalized.map((label) => {
+ const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
+ return tag?.id;
+ }).filter(Boolean);
+ }
+
+ setLoading(true);
+ try {
+ if (action === 'add') {
+ const createdIds = [];
+ for (const label of normalized) {
+ let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
+ if (!tag) {
+ const payload = tagManager.buildPayload({ label });
+ const { data } = await apiClient.post('/tags', payload);
+ tag = data;
+ await refreshTags();
+ }
+ createdIds.push(tag.id);
+ }
+ tagIds = Array.from(new Set(createdIds));
+ }
+
+ tagIds = Array.from(new Set(tagIds));
+
+ if (!tagIds.length) {
+ return { ok: false, reason: 'no-tags' };
+ }
+
+ await apiClient.post('/documents/bulk/tags', {
+ document_ids: targetDocumentIds,
+ tag_ids: tagIds,
+ action,
+ });
+
+ await refreshCurrentFolder();
+
+ return {
+ ok: true,
+ tagCount: tagIds.length,
+ docsCount: targetDocumentIds.length,
+ };
+ } catch (error) {
+ const message =
+ error.response?.data?.error ||
+ (action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
+ notifyApiError(error, message);
+ return { ok: false, reason: 'request-failed' };
+ } finally {
+ setLoading(false);
+ }
+ },
+ [
+ resolveTargetDocumentIds,
+ tags,
+ refreshTags,
+ refreshCurrentFolder,
+ notifyApiError,
+ setLoading,
+ tagManager,
+ apiClient,
+ ],
+ );
+
+ const handleBulkTagAddFromDetail = useCallback(
+ async ({ label, input, documentIds }) => {
+ const trimmed = typeof label === 'string' ? label.trim() : '';
+ if (!trimmed) {
+ setStatusMessage('Enter a tag label.', 'error');
+ return;
+ }
+ const targetIds = resolveTargetDocumentIds(documentIds);
+ if (!targetIds.length) {
+ setStatusMessage('Select documents before assigning tags.', 'error');
+ return;
+ }
+ const result = await bulkTagOperation({
+ labels: [trimmed],
+ action: 'add',
+ documentIds: targetIds,
+ });
+ if (result?.ok) {
+ const { tagCount, docsCount } = result;
+ setStatusMessage(
+ `Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
+ docsCount === 1 ? '' : 's'
+ }.`,
+ 'success',
+ );
+ if (input) {
+ input.value = '';
+ }
+ }
+ },
+ [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
+ );
+
+ const handleBulkTagRemoveFromDetail = useCallback(
+ async ({ label, input, documentIds }) => {
+ const trimmed = typeof label === 'string' ? label.trim() : '';
+ if (!trimmed) {
+ setStatusMessage('Enter a tag label to remove.', 'error');
+ return;
+ }
+ const targetIds = resolveTargetDocumentIds(documentIds);
+ if (!targetIds.length) {
+ setStatusMessage('Select documents before removing tags.', 'error');
+ return;
+ }
+ const result = await bulkTagOperation({
+ labels: [trimmed],
+ action: 'remove',
+ documentIds: targetIds,
+ });
+ if (result?.ok) {
+ const { docsCount } = result;
+ setStatusMessage(
+ `Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
+ 'success',
+ );
+ if (input) {
+ input.value = '';
+ }
+ } else if (result?.reason === 'tag-missing') {
+ setStatusMessage(`Tag “${result.label}” not found.`, 'error');
+ }
+ },
+ [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage],
+ );
+
+ const handleBulkSelectionReanalyze = useCallback(
+ async (documentIdsOverride = null) => {
+ const targetIds = resolveTargetDocumentIds(documentIdsOverride);
+ if (!targetIds.length) {
+ setStatusMessage('Select documents before requesting re-analysis.', 'error');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { data } = await apiClient.post('/documents/bulk/reanalyze', {
+ document_ids: targetIds,
+ force: true,
+ });
+ const queued = data?.queued ?? targetIds.length;
+ setStatusMessage(
+ `Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
+ 'success',
+ );
+ } catch (error) {
+ const message =
+ error.response?.data?.error || 'Failed to queue document re-analysis.';
+ notifyApiError(error, message);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient],
+ );
+
+ return {
+ bulkTagOperation,
+ handleBulkTagAddFromDetail,
+ handleBulkTagRemoveFromDetail,
+ handleBulkSelectionReanalyze,
+ };
+};
+
+export default useDocumentTagging;
+
diff --git a/frontend/src/hooks/documents/useDocumentUploads.js b/frontend/src/hooks/documents/useDocumentUploads.js
new file mode 100644
index 0000000..a04bd22
--- /dev/null
+++ b/frontend/src/hooks/documents/useDocumentUploads.js
@@ -0,0 +1,303 @@
+import { useCallback, useRef, useState } from 'react';
+import useFileDrop from './useFileDrop';
+import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
+
+const useDocumentUploads = ({
+ apiClient,
+ token,
+ selectedFolder,
+ currentFolderName,
+ ensureFolderData,
+ refreshCurrentFolder,
+ notifyApiError,
+ setStatusMessage,
+ setLoading,
+ shellRef,
+}) => {
+ const [dropOverlayState, setDropOverlayState] = useState({
+ active: false,
+ folderName: DEFAULT_FOLDER_NAME,
+ });
+ const dragCounterRef = useRef(0);
+ const folderPathCacheRef = useRef(new Map());
+
+ const uploadFile = useCallback(
+ async (file, targetFolderId) => {
+ if (!file || file.size === 0) {
+ setStatusMessage('Skipped empty file.', 'error');
+ return null;
+ }
+
+ const formData = new FormData();
+ formData.append('file', file, file.name);
+ if (targetFolderId && targetFolderId !== 'root') {
+ formData.append('folder_id', targetFolderId);
+ }
+
+ try {
+ const { data, status } = await apiClient.post('/documents', formData);
+ const duplicate = data?.reused || status === 200;
+ setStatusMessage(
+ duplicate
+ ? `${file.name} already exists; reused existing document.`
+ : `Uploaded ${file.name}`,
+ duplicate ? 'info' : 'success',
+ );
+ return data;
+ } catch (error) {
+ const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
+ notifyApiError(error, message);
+ throw error;
+ }
+ },
+ [apiClient, notifyApiError, setStatusMessage],
+ );
+
+ const ensureFolderPathOnServer = useCallback(
+ async (baseFolderId, segments) => {
+ const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
+ if (trimmedSegments.length === 0) {
+ return baseFolderId ?? null;
+ }
+
+ const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
+ const cache = folderPathCacheRef.current;
+ if (cache.has(cacheKey)) {
+ return cache.get(cacheKey);
+ }
+
+ const payload = {
+ parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
+ segments: trimmedSegments,
+ };
+
+ const { data } = await apiClient.post('/folders/path', payload);
+ cache.set(cacheKey, data.folder.id);
+ return data.folder.id;
+ },
+ [apiClient],
+ );
+
+ const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
+ if (!dataTransfer) {
+ throw new Error('No drop payload found.');
+ }
+
+ const items = Array.from(dataTransfer.items || []);
+ console.info('[Uploads] drop start', {
+ items: items.length,
+ files: (dataTransfer.files || []).length,
+ });
+
+ const results = [];
+ const seenKeys = new Set();
+
+ const pushFile = (file, ancestors = []) => {
+ if (!file) return;
+ const segments = (ancestors || []).filter(Boolean);
+ const key = `${segments.join('/')}/${file.name}:${file.size}`;
+ if (seenKeys.has(key)) {
+ return;
+ }
+ seenKeys.add(key);
+ results.push({ file, segments });
+ };
+
+ const readAllEntries = async (reader) => {
+ const entries = [];
+ let batch = [];
+ do {
+ // eslint-disable-next-line no-await-in-loop
+ batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
+ if (batch.length) {
+ entries.push(...batch);
+ }
+ } while (batch.length);
+ return entries;
+ };
+
+ const walkEntry = async (entry, ancestors = []) => {
+ if (!entry) return;
+ if (entry.isFile) {
+ const file = await new Promise((resolve, reject) => {
+ try {
+ entry.file(resolve, reject);
+ } catch (error) {
+ console.warn('[Uploads] entry.file failed', error);
+ reject(error);
+ }
+ });
+ pushFile(file, ancestors);
+ return;
+ }
+ if (entry.isDirectory) {
+ const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
+ const reader = entry.createReader();
+ const entries = await readAllEntries(reader);
+ for (const child of entries) {
+ // eslint-disable-next-line no-await-in-loop
+ await walkEntry(child, nextAncestors);
+ }
+ }
+ };
+
+ await Promise.all(
+ items.map(async (item, index) => {
+ if (item.kind !== 'file') return;
+
+ const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
+ if (fileFromItem) {
+ const relativePath =
+ typeof fileFromItem.webkitRelativePath === 'string'
+ ? fileFromItem.webkitRelativePath
+ : '';
+ const segments = relativePath
+ ? relativePath
+ .split('/')
+ .slice(0, -1)
+ .filter(Boolean)
+ : [];
+ pushFile(fileFromItem, segments);
+ }
+
+ if (typeof item.webkitGetAsEntry === 'function') {
+ try {
+ const entry = item.webkitGetAsEntry();
+ if (entry) {
+ await walkEntry(entry, []);
+ return;
+ }
+ } catch (error) {
+ console.warn('[Uploads] webkitGetAsEntry failed', error);
+ }
+ }
+
+ if (!fileFromItem) {
+ console.info('[Uploads] item missing file handle', index);
+ }
+ }),
+ );
+
+ Array.from(dataTransfer.files || []).forEach((file) => {
+ if (!file) return;
+ const relativePath =
+ typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
+ const segments = relativePath
+ ? relativePath
+ .split('/')
+ .slice(0, -1)
+ .filter(Boolean)
+ : [];
+ pushFile(file, segments);
+ });
+
+ if (!results.length) {
+ throw new Error('No files detected in drop payload.');
+ }
+
+ console.info('[Uploads] prepared files', results.length);
+
+ return results;
+ }, []);
+
+ const handleFileDrop = useCallback(
+ async (dataTransfer, targetFolderId) => {
+ if (!token) {
+ setStatusMessage('Please log in before uploading.', 'error');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ folderPathCacheRef.current.clear();
+
+ let extracted;
+ try {
+ extracted = await extractFilesFromDataTransfer(dataTransfer);
+ } catch (error) {
+ const message = error.message || 'Failed to process dropped files.';
+ notifyApiError(error, message);
+ return;
+ }
+
+ if (!extracted.length) {
+ setStatusMessage('No files to upload.', 'info');
+ return;
+ }
+ const baseFolderId =
+ targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
+
+ for (const { file, segments } of extracted) {
+ // eslint-disable-next-line no-await-in-loop
+ const destinationId = segments.length
+ ? await ensureFolderPathOnServer(baseFolderId, segments)
+ : baseFolderId;
+
+ const uploadTarget =
+ destinationId ??
+ (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
+
+ // eslint-disable-next-line no-await-in-loop
+ await uploadFile(file, uploadTarget);
+ }
+
+ await refreshCurrentFolder();
+
+ if (
+ targetFolderId &&
+ targetFolderId !== 'root' &&
+ targetFolderId !== selectedFolder
+ ) {
+ await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
+ }
+ } catch (error) {
+ notifyApiError(error, 'Failed to upload files.');
+ } finally {
+ setLoading(false);
+ }
+ },
+ [
+ token,
+ extractFilesFromDataTransfer,
+ ensureFolderPathOnServer,
+ uploadFile,
+ refreshCurrentFolder,
+ selectedFolder,
+ ensureFolderData,
+ notifyApiError,
+ setStatusMessage,
+ setLoading,
+ ],
+ );
+
+ useFileDrop({
+ shellRef,
+ token,
+ currentFolderName,
+ selectedFolder,
+ handleFileDrop,
+ hasFiles,
+ defaultFolderName: DEFAULT_FOLDER_NAME,
+ dragCounterRef,
+ dropOverlayState,
+ setDropOverlayState,
+ });
+
+ const resetUploadsState = useCallback(() => {
+ dragCounterRef.current = 0;
+ setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
+ }, []);
+
+ return {
+ dropOverlayState,
+ setDropOverlayState,
+ dragCounterRef,
+ handleFileDrop,
+ uploadFile,
+ extractFilesFromDataTransfer,
+ resetUploadsState,
+ };
+};
+
+export default useDocumentUploads;
diff --git a/frontend/src/hooks/documents/useDocuments.js b/frontend/src/hooks/documents/useDocuments.js
new file mode 100644
index 0000000..206eb50
--- /dev/null
+++ b/frontend/src/hooks/documents/useDocuments.js
@@ -0,0 +1,91 @@
+import { useCallback, useState } from 'react';
+
+const useDocuments = ({ setSearchResults, setFolderContents }) => {
+ const [documents, setDocuments] = useState([]);
+
+ const mapDocumentCaches = useCallback(
+ (mapper) => {
+ if (typeof mapper !== 'function') {
+ return;
+ }
+
+ const applyToList = (list) => {
+ let changed = false;
+ const next = list.map((doc) => {
+ const updated = mapper(doc);
+ if (updated === undefined || updated === doc) {
+ return doc;
+ }
+ changed = true;
+ return updated;
+ });
+ return changed ? next : list;
+ };
+
+ setDocuments((prev) => applyToList(prev));
+ setSearchResults((prev) => {
+ if (!Array.isArray(prev)) {
+ return prev;
+ }
+ return applyToList(prev);
+ });
+ setFolderContents((prev) => {
+ if (!prev.size) {
+ return prev;
+ }
+ let changed = false;
+ const next = new Map();
+ prev.forEach((contents, key) => {
+ const docs = Array.isArray(contents?.documents) ? contents.documents : null;
+ if (!docs || docs.length === 0) {
+ next.set(key, contents);
+ return;
+ }
+ let docsChanged = false;
+ const updatedDocs = docs.map((doc) => {
+ const updated = mapper(doc);
+ if (updated === undefined || updated === doc) {
+ return doc;
+ }
+ docsChanged = true;
+ return updated;
+ });
+ if (docsChanged) {
+ changed = true;
+ next.set(key, { ...contents, documents: updatedDocs });
+ } else {
+ next.set(key, contents);
+ }
+ });
+ return changed ? next : prev;
+ });
+ },
+ [setFolderContents, setSearchResults],
+ );
+
+ const updateDocumentCaches = useCallback(
+ (documentId, updater) => {
+ if (!documentId || typeof updater !== 'function') {
+ return;
+ }
+
+ mapDocumentCaches((doc) => {
+ if (!doc || doc.id !== documentId) {
+ return doc;
+ }
+ const updated = updater(doc);
+ return updated === undefined ? doc : updated;
+ });
+ },
+ [mapDocumentCaches],
+ );
+
+ return {
+ documents,
+ setDocuments,
+ mapDocumentCaches,
+ updateDocumentCaches,
+ };
+};
+
+export default useDocuments;
diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js
new file mode 100644
index 0000000..d8d1765
--- /dev/null
+++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js
@@ -0,0 +1,2674 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ matchPath,
+ useLocation,
+ useMatch,
+ useNavigate,
+} from 'react-router-dom';
+import AssetManager, {
+ getAssetFromVersion,
+ resolveDocumentAssetUrl,
+} 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 { useDetailPanel } from '../../app/useDetailPanel';
+import useWorkspaceSelection from '../../app/useWorkspaceSelection';
+import { useEntryPointerHandler as useEntryPointerCore } from '../../documents/useEntryPointer';
+import { isTagTransferEvent } from '../../documents/tagTransfer';
+import useDocumentPreview from '../../app/useDocumentPreview';
+import useDeskWorkspaceProps from '../../desktop/useDeskWorkspaceProps';
+import {
+ ASSET_PRESIGN_TTL_MS,
+ DEFAULT_FOLDER_NAME,
+ DEFAULT_SORT_DIRECTION,
+ DEFAULT_SORT_FIELD,
+ TAG_FILTER_UNTAGGED,
+ createRootNode,
+ getRowId,
+ hasFiles,
+ 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 useFolderTree from './useFolderTree';
+import useDocumentTagging from './useDocumentTagging';
+import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
+import useDocumentUploads from './useDocumentUploads';
+import useDocumentDragHandlers from './useDocumentDragHandlers';
+import useDocumentMutations from './useDocumentMutations';
+
+const noop = () => {};
+
+const useDocumentsWorkspace = ({
+ documentsViewMode = 'list',
+ documentsSortField = DEFAULT_SORT_FIELD,
+ documentsSortDirection = DEFAULT_SORT_DIRECTION,
+ documentsSortFieldRef,
+ documentsSortDirectionRef,
+ onDocumentsViewModeChange,
+ onDocumentsSortFieldChange,
+ onDocumentsSortDirectionToggle,
+ searchIncludeDescendants = true,
+ onToggleSearchIncludeDescendants,
+ onSetSearchIncludeDescendants,
+ sortRefreshReadyRef,
+ deskHelpOpen = false,
+ setDeskHelpOpen,
+ handleDeskExit,
+} = {}) => {
+ const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop;
+ const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop;
+ const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop;
+ const toggleSearchIncludeDescendants = onToggleSearchIncludeDescendants || noop;
+ const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop;
+ const setDeskHelpOpenSafe = setDeskHelpOpen || noop;
+ const handleDeskExitSafe = handleDeskExit || 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: tenantOptions = [] } = appState;
+ const tenantName = tenant?.name || tenant?.slug || null;
+ const currentTenantId = tenant?.id || null;
+ 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 (typeof document === 'undefined') {
+ return;
+ }
+ if (tagRemovalCursorActiveRef.current === active) {
+ return;
+ }
+ const body = document.body;
+ if (!body) {
+ return;
+ }
+ tagRemovalCursorActiveRef.current = active;
+ if (active) {
+ body.classList.add('desk-cursor-remove');
+ } else {
+ body.classList.remove('desk-cursor-remove');
+ }
+ }, []);
+ const documentsRouteMatch = useMatch('/documents');
+ const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
+ const documentsDetailRouteMatch = useMatch('/documents/:documentId');
+ const isDocumentsRoute = Boolean(
+ documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
+ );
+
+ const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
+ const [draggedFolderId, setDraggedFolderId] = useState(null);
+ const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
+ const shellRef = useRef(null);
+ const assetManagerRef = useRef(null);
+ if (!assetManagerRef.current) {
+ assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
+ }
+ const assetManager = assetManagerRef.current;
+
+ const extractDocumentFromResponse = useCallback(
+ (payload) => {
+ if (!payload) {
+ return null;
+ }
+ const hydratedDetail = assetManager.hydrateDetail(payload);
+ return hydratedDetail?.document || payload.document || payload;
+ },
+ [assetManager],
+ );
+
+ 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,
+ clearSelection,
+ handleEntrySelection,
+ promoteSelectionOrder: promoteSelectionOrderRaw,
+ configureSelectionEnvironment,
+ } = selection;
+
+ const selectionHelpers = useMemo(
+ () => ({
+ setSelectedEntries,
+ setSelectionOrder,
+ selectionOrderRef,
+ selectionAnchorRef,
+ setFocusedDocumentId,
+ focusedDocumentId,
+ selectionInitializedRef,
+ }),
+ [
+ setSelectedEntries,
+ setSelectionOrder,
+ selectionOrderRef,
+ selectionAnchorRef,
+ setFocusedDocumentId,
+ focusedDocumentId,
+ selectionInitializedRef,
+ ],
+ );
+
+ const [folderContents, setFolderContents] = useState(() => new Map());
+ const folderContentsRef = useRef(folderContents);
+ useEffect(() => {
+ folderContentsRef.current = folderContents;
+ }, [folderContents]);
+
+ const setSearchResultsRef = useRef(() => {});
+ const setSearchResultsProxy = useCallback((value) => {
+ if (typeof setSearchResultsRef.current === 'function') {
+ setSearchResultsRef.current(value);
+ }
+ }, []);
+
+ const {
+ documents,
+ setDocuments,
+ mapDocumentCaches,
+ updateDocumentCaches,
+ } = useDocuments({
+ setSearchResults: setSearchResultsProxy,
+ setFolderContents,
+ });
+
+ const {
+ folderNodes,
+ setFolderNodes,
+ selectedFolder,
+ setSelectedFolder,
+ currentFolder,
+ setCurrentFolder,
+ currentSubfolders,
+ setCurrentSubfolders,
+ currentFolderName,
+ folderOptions,
+ folderLabelMap,
+ applySelectedFolder,
+ ensureFolderData,
+ ensureFolderAncestorsLoaded,
+ expandFolderAncestors,
+ isInvalidFolderDrop,
+ } = useFolderTree({
+ initialSelectedFolder: routeFolderId || 'root',
+ assetManager,
+ apiClient: api,
+ tenantIdRef,
+ documentsSortFieldRef: activeSortFieldRef,
+ documentsSortDirectionRef: activeSortDirectionRef,
+ selectionHelpers,
+ setDocuments,
+ setFolderContents,
+ folderContentsRef,
+ });
+
+ const {
+ searchQuery,
+ setSearchQuery,
+ searchResults,
+ setSearchResults,
+ searchLoading,
+ activeTagFilters,
+ setActiveTagFilters,
+ activeCorrespondentFilters,
+ setActiveCorrespondentFilters,
+ toggleTagFilter,
+ toggleCorrespondentFilter,
+ isFilterActive,
+ clearFilters,
+ handleSearchChange,
+ handleSearchSubmit,
+ } = useDocumentsSearch({
+ api,
+ assetManager,
+ token,
+ selectedFolder,
+ navigate,
+ locationPathname: location.pathname,
+ isDocumentsRoute,
+ selectionHelpers,
+ searchIncludeDescendants,
+ documentsSortField,
+ documentsSortDirection,
+ notifyApiError,
+ setLoading,
+ setSearchIncludeDescendants,
+ });
+
+ useEffect(() => {
+ setSearchResultsRef.current = setSearchResults;
+ }, [setSearchResults]);
+
+ const moveFolder = useCallback(
+ async (folderId, targetFolderId) => {
+ const node = folderNodes.get(folderId);
+ if (!node) {
+ setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
+ return;
+ }
+
+ const previousParentKey = node.parentId ?? 'root';
+ const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root';
+
+ if (previousParentKey === targetKey) {
+ return;
+ }
+
+ const parent_id = targetKey === 'root' ? null : targetKey;
+
+ try {
+ await api.patch(`/folders/${folderId}`, { parent_id });
+
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ const currentNode = next.get(folderId);
+ if (!currentNode) {
+ return prev;
+ }
+
+ next.set(folderId, { ...currentNode, parentId: parent_id ?? null });
+
+ const previousParent = next.get(previousParentKey);
+ if (previousParent) {
+ const remainingChildren = (previousParent.children || []).filter(
+ (childId) => childId !== folderId,
+ );
+ next.set(previousParentKey, {
+ ...previousParent,
+ children: remainingChildren,
+ hasChildren: remainingChildren.length > 0,
+ });
+ }
+
+ if (!next.has(targetKey)) {
+ next.set(targetKey, {
+ id: targetKey,
+ name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder',
+ parentId: targetKey === 'root' ? null : null,
+ children: [],
+ expanded: targetKey === 'root',
+ loaded: false,
+ hasChildren: false,
+ });
+ }
+
+ const targetNode = next.get(targetKey);
+ if (targetNode && !targetNode.children.includes(folderId)) {
+ next.set(targetKey, {
+ ...targetNode,
+ children: [...targetNode.children, folderId],
+ hasChildren: true,
+ });
+ }
+
+ return next;
+ });
+
+ const refreshTargets = new Set([previousParentKey, targetKey]);
+ for (const key of refreshTargets) {
+ await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
+ }
+
+ if (selectedFolder === folderId) {
+ await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
+ setSelectedFolder(folderId);
+ }
+
+ setStatusMessage('Folder moved.', 'success');
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to move folder.';
+ notifyApiError(error, message);
+
+ const refreshTargets = new Set([previousParentKey, targetKey]);
+ for (const key of refreshTargets) {
+ await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
+ }
+ }
+ },
+ [
+ folderNodes,
+ ensureFolderData,
+ selectedFolder,
+ setSelectedFolder,
+ setFolderNodes,
+ notifyApiError,
+ setStatusMessage,
+ ],
+ );
+
+ const {
+ previewEntries,
+ ensurePreviewData,
+ openDocumentPreview,
+ closeDocumentPreview,
+ resetPreviewState,
+ removePreviewEntries,
+ } = useDocumentPreview({
+ routeDocumentId: previewDocumentId,
+ documents,
+ searchResults,
+ setDocuments,
+ selectedFolder,
+ assetManager,
+ api,
+ resolveApiPath,
+ notifyApiError,
+ navigate,
+ locationPathname: location.pathname,
+ locationSearch: location.search,
+ detailPanelControlRef,
+ setActivePreviewId,
+ });
+
+ 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());
+
+
+ const showingSearchResults = searchResults !== null;
+
+ const visibleDocuments = useMemo(
+ () => (showingSearchResults ? searchResults : documents),
+ [showingSearchResults, searchResults, documents],
+ );
+
+ const visibleDocumentIds = useMemo(
+ () => visibleDocuments.map((doc) => doc.id),
+ [visibleDocuments],
+ );
+
+ 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 documentLookup = useMemo(() => {
+ const map = new Map();
+ const push = (items) => {
+ (items || []).forEach((doc) => {
+ if (doc?.id) {
+ map.set(doc.id, doc);
+ }
+ });
+ };
+
+ push(documents);
+ if (Array.isArray(searchResults)) {
+ push(searchResults);
+ }
+ return map;
+ }, [documents, searchResults]);
+
+ 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,
+ refreshCurrentFolder,
+ resolveTargetDocumentIds,
+ notifyApiError,
+ setStatusMessage,
+ setLoading,
+ });
+
+ const {
+ dropOverlayState,
+ handleFileDrop,
+ 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,
+ refreshCurrentFolder,
+ notifyApiError,
+ setStatusMessage,
+ });
+
+ 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);
+ setSearchResults(null);
+ setTags([]);
+ setCorrespondents([]);
+ setSearchQuery('');
+ setActiveTagFilters([]);
+ setActiveCorrespondentFilters([]);
+ setActivePreviewId(null);
+ detailPanelControlRef.current.close();
+ assetManager.reset();
+ resetPreviewState();
+ resetUploadsState();
+ 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,
+ setSearchResults,
+ setTags,
+ setCorrespondents,
+ setSearchQuery,
+ setActiveTagFilters,
+ setActiveCorrespondentFilters,
+ setActivePreviewId,
+ resetPreviewState,
+ resetUploadsState,
+ ]);
+
+ useEffect(() => {
+ if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
+ resetWorkspaceState();
+ }
+ }, [appStatus, resetWorkspaceState]);
+
+
+ const removeDocumentsFromCaches = useCallback(
+ (documentIds) => {
+ if (!documentIds || documentIds.length === 0) {
+ return;
+ }
+
+ const idSet = new Set(documentIds);
+
+ setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
+ setSearchResults((prev) => {
+ if (!Array.isArray(prev)) {
+ return prev;
+ }
+ const filtered = prev.filter((doc) => !idSet.has(doc.id));
+ return filtered.length === prev.length ? prev : filtered;
+ });
+
+ setFolderContents((prev) => {
+ if (!prev.size) {
+ return prev;
+ }
+ let changed = false;
+ const next = new Map();
+ prev.forEach((contents, key) => {
+ const docs = Array.isArray(contents?.documents) ? contents.documents : null;
+ if (!docs || docs.length === 0) {
+ next.set(key, contents);
+ return;
+ }
+ const filtered = docs.filter((doc) => !idSet.has(doc.id));
+ if (filtered.length !== docs.length) {
+ changed = true;
+ next.set(key, { ...contents, documents: filtered });
+ } else {
+ next.set(key, contents);
+ }
+ });
+ return changed ? next : prev;
+ });
+
+ removePreviewEntries(Array.from(idSet));
+ },
+ [setDocuments, setSearchResults, setFolderContents, removePreviewEntries],
+ );
+
+ const {
+ moveDocumentsToFolder,
+ handleThumbnailRegeneration,
+ handleDocumentsDelete,
+ handleDocumentTagAdd,
+ handleDocumentTagAttach,
+ handleFolderDelete,
+ } = useDocumentMutations({
+ api,
+ token,
+ documentLookup,
+ folderLabelMap,
+ ensureFolderData,
+ selectedFolder,
+ setSelectedFolder,
+ setDocuments,
+ setFolderContents,
+ setSearchResults,
+ setSelectedEntries,
+ setSelectionOrder,
+ selectionOrderRef,
+ selectionAnchorRef,
+ setFocusedDocumentId,
+ focusedDocumentId,
+ setFocusedRowKey,
+ focusedRowKey,
+ notifyApiError,
+ setStatusMessage,
+ setLoading,
+ mapDocumentCaches,
+ applySelectedFolder,
+ folderNodes,
+ setFolderNodes,
+ removeDocumentsFromCaches,
+ closeDocumentPreview,
+ previewDocumentId,
+ refreshCurrentFolder,
+ documentsViewMode,
+ updateDocumentCaches,
+ tagLookupById,
+ tags,
+ refreshTags,
+ tagManager,
+ });
+
+ const navigableRows = useMemo(() => {
+ const entries = [];
+ if (!showingSearchResults) {
+ currentSubfolders.forEach((folder) => {
+ const key = resolveFolderRowKey(folder.id);
+ if (key) {
+ entries.push({ key, type: 'folder', id: folder.id });
+ }
+ });
+ }
+ visibleDocuments.forEach((doc) => {
+ const key = resolveDocumentRowKey(doc.id);
+ if (key) {
+ entries.push({ key, type: 'document', id: doc.id });
+ }
+ });
+ return entries;
+ }, [showingSearchResults, currentSubfolders, visibleDocuments]);
+
+ const navigableRowKeys = useMemo(
+ () => navigableRows.map((entry) => entry.key),
+ [navigableRows],
+ );
+
+ useEffect(() => {
+ configureSelectionEnvironment({
+ visibleRowKeySet,
+ navigableRowKeys,
+ });
+ }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
+
+ const promoteSelectionOrder = useCallback(
+ (docId) => {
+ if (!docId) return;
+ promoteSelectionOrderRaw(docId);
+ const rowKey = resolveDocumentRowKey(docId);
+ if (rowKey) {
+ selectionAnchorRef.current = rowKey;
+ }
+ setFocusedDocumentId(docId);
+ setActivePreviewId(docId);
+ },
+ [
+ promoteSelectionOrderRaw,
+ selectionAnchorRef,
+ setFocusedDocumentId,
+ setActivePreviewId,
+ ],
+ );
+
+ const clearDocumentSelection = useCallback(() => {
+ clearSelection();
+ }, [clearSelection]);
+
+ const prevFocusedDocIdRef = useRef(focusedDocumentId);
+ useEffect(() => {
+ const previous = prevFocusedDocIdRef.current;
+ if (previous === focusedDocumentId) {
+ return;
+ }
+ prevFocusedDocIdRef.current = focusedDocumentId;
+ if (focusedDocumentId) {
+ setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId));
+ } else {
+ setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null));
+ }
+ }, [focusedDocumentId, setFocusedRowKey]);
+
+ useEffect(() => {
+ if (!navigableRowKeys.length) {
+ if (focusedRowKey) {
+ setFocusedRowKey(null);
+ }
+ return;
+ }
+
+ if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
+ return;
+ }
+
+ const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null;
+ if (docKey && navigableRowKeys.includes(docKey)) {
+ setFocusedRowKey(docKey);
+ return;
+ }
+
+ const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key));
+ if (selectedKey) {
+ setFocusedRowKey(selectedKey);
+ return;
+ }
+
+ if (focusedRowKey) {
+ setFocusedRowKey(null);
+ }
+ }, [
+ focusedRowKey,
+ focusedDocumentId,
+ navigableRowKeys,
+ selectedEntries,
+ setFocusedRowKey,
+ ]);
+ const loadFolder = useCallback(
+ async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
+ const targetId = folderId || 'root';
+ setSelectedFolder(targetId);
+ await ensureFolderAncestorsLoaded(targetId);
+ expandFolderAncestors(targetId);
+ if (showLoading) setLoading(true);
+ try {
+ const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
+ if (targetId !== 'root') {
+ try {
+ await ensureFolderData('root', {
+ force: false,
+ includeDocuments: false,
+ prefetchDepth: 1,
+ });
+ } catch (error) {
+ console.warn('Failed to refresh root folder tree', error);
+ }
+ }
+ applySelectedFolder(targetId, contents);
+ if (!preserveSearch) {
+ setSearchResults(null);
+ }
+ } catch (error) {
+ notifyApiError(error, 'Failed to load folder contents.');
+ } finally {
+ if (showLoading) setLoading(false);
+ }
+ },
+ [
+ ensureFolderData,
+ applySelectedFolder,
+ notifyApiError,
+ ensureFolderAncestorsLoaded,
+ expandFolderAncestors,
+ setSearchResults,
+ setSelectedFolder,
+ ],
+ );
+
+ const selectFolder = useCallback(
+ async (folderId, { replace = false, immediate = false } = {}) => {
+ const targetId = folderId && folderId !== 'root' ? folderId : 'root';
+
+ await ensureFolderAncestorsLoaded(targetId);
+ expandFolderAncestors(targetId);
+
+ if (!navigate || immediate) {
+ await loadFolder(targetId, { preserveSearch: isFilterActive });
+ setSelectedFolder(targetId);
+ return;
+ }
+
+ const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
+ navigate(path, { replace });
+ },
+ [
+ ensureFolderAncestorsLoaded,
+ expandFolderAncestors,
+ navigate,
+ loadFolder,
+ isFilterActive,
+ setSelectedFolder,
+ ],
+ );
+
+ 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,
+ ]);
+
+ const handleBulkCorrespondentAdd = useCallback(
+ async ({ name, input, documentIds }) => {
+ const trimmed = typeof name === 'string' ? name.trim() : '';
+ if (!trimmed) {
+ setStatusMessage('Correspondent name is required.', 'error');
+ return;
+ }
+ const targets = resolveTargetDocumentIds(documentIds);
+ if (!targets.length) {
+ setStatusMessage('Select documents before assigning correspondents.', 'error');
+ return;
+ }
+
+ let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
+ if (!target) {
+ try {
+ target = await handleCorrespondentCreate({ name: trimmed });
+ } catch (error) {
+ return;
+ }
+ }
+
+ if (!target?.id) {
+ setStatusMessage('Unable to resolve correspondent.', 'error');
+ return;
+ }
+
+ const response = await api.post('/documents/bulk/correspondents', {
+ document_ids: targets,
+ assignments: [
+ {
+ correspondent_id: target.id,
+ },
+ ],
+ action: 'add',
+ });
+
+ const { assigned = 0, removed = 0 } = response.data || {};
+
+ await refreshCurrentFolder();
+ const assignedSuffix = assigned === 1 ? '' : 's';
+ if (removed > 0) {
+ const removedSuffix = removed === 1 ? '' : 's';
+ setStatusMessage(
+ `Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`,
+ 'success',
+ );
+ } else {
+ setStatusMessage(
+ `Correspondent assigned to ${assigned} document${assignedSuffix}.`,
+ 'success',
+ );
+ }
+
+ if (input) {
+ input.value = '';
+ }
+ },
+ [
+ correspondentLookupByName,
+ handleCorrespondentCreate,
+ refreshCurrentFolder,
+ resolveTargetDocumentIds,
+ setStatusMessage,
+ ],
+ );
+
+ const handleBulkCorrespondentRemove = useCallback(
+ async ({ assignments = [], documentIds }) => {
+ if (!assignments.length) {
+ setStatusMessage('Select a correspondent to remove.', 'error');
+ return;
+ }
+
+ const targets = resolveTargetDocumentIds(documentIds);
+
+ if (!targets.length) {
+ setStatusMessage('Select documents before removing correspondents.', 'error');
+ return;
+ }
+
+ const normalizedAssignments = assignments.map((entry) => ({
+ correspondent_id: entry.correspondent_id,
+ }));
+
+ const response = await api.post('/documents/bulk/correspondents', {
+ document_ids: targets,
+ assignments: normalizedAssignments,
+ action: 'remove',
+ });
+
+ const { assigned = 0, removed = 0 } = response.data || {};
+ await refreshCurrentFolder();
+
+ if (removed > 0) {
+ const removedSuffix = removed === 1 ? '' : 's';
+ setStatusMessage(
+ `Correspondent removed from ${removed} link${removedSuffix}.`,
+ 'success',
+ );
+ } else if (assigned > 0) {
+ const assignedSuffix = assigned === 1 ? '' : 's';
+ setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
+ } else {
+ setStatusMessage('No correspondents changed.', 'info');
+ }
+ },
+ [refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
+ );
+
+ 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 ensureAssetUrl = useCallback(
+ async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
+ if (!documentId || !asset?.id) {
+ return null;
+ }
+
+ try {
+ const entry = await assetManager.ensureAsset(documentId, asset, {
+ force,
+ start,
+ limit,
+ });
+
+ if (!entry) {
+ return null;
+ }
+
+ setDocuments((prev) =>
+ prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)),
+ );
+
+ setSearchResults((prev) =>
+ Array.isArray(prev)
+ ? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc))
+ : prev,
+ );
+
+ return entry;
+ } catch (error) {
+ notifyApiError(error, 'Unable to refresh document asset.');
+ throw error;
+ }
+ },
+ [assetManager, setDocuments, setSearchResults, notifyApiError],
+ );
+
+
+
+ const handleDocumentTitleUpdate = useCallback(
+ async (documentId, nextTitle) => {
+ const trimmed = nextTitle.trim();
+ if (!trimmed) {
+ setStatusMessage('Document title cannot be empty.', 'error');
+ return false;
+ }
+
+ setLoading(true);
+ try {
+ const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
+ const updatedDocument = extractDocumentFromResponse(data);
+
+ updateDocumentCaches(documentId, (doc) => {
+ if (updatedDocument) {
+ return { ...doc, ...updatedDocument };
+ }
+ return { ...doc, title: trimmed };
+ });
+
+ setStatusMessage('Document title updated.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to update document title.';
+ notifyApiError(error, message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse],
+ );
+
+ const handleDocumentIssuedUpdate = useCallback(
+ async (documentId, nextIssuedDate) => {
+ setLoading(true);
+ const payload = { issued_at: nextIssuedDate || null };
+ try {
+ const { data } = await api.patch(`/documents/${documentId}`, payload);
+ const updatedDocument = extractDocumentFromResponse(data);
+
+ updateDocumentCaches(documentId, (doc) => {
+ if (updatedDocument) {
+ return { ...doc, ...updatedDocument };
+ }
+ return { ...doc, issued_at: payload.issued_at };
+ });
+
+ const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
+ setStatusMessage(message, 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to update issued date.';
+ notifyApiError(error, message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches],
+ );
+
+ const applyTagRemovalToCaches = useCallback(
+ (documentId, tagId) => {
+ if (!documentId || !tagId) {
+ return;
+ }
+
+ updateDocumentCaches(documentId, (doc) => {
+ if (!Array.isArray(doc.tags)) {
+ return doc;
+ }
+ const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
+ if (nextTags.length === doc.tags.length) {
+ return doc;
+ }
+ return { ...doc, tags: nextTags };
+ });
+ },
+ [updateDocumentCaches],
+ );
+
+ const handleTagRemove = useCallback(
+ async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
+ if (!documentId || !tagId) {
+ return false;
+ }
+
+ try {
+ await api.delete(`/documents/${documentId}/tags/${tagId}`);
+ applyTagRemovalToCaches(documentId, tagId);
+ if (refreshTagList) {
+ await refreshTags();
+ }
+ if (showMessage) {
+ setStatusMessage('Tag removed.', 'success');
+ }
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to remove tag.';
+ notifyApiError(error, message);
+ return false;
+ }
+ },
+ [refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches],
+ );
+
+
+
+
+
+
+
+
+ const handleDeleteSelection = useCallback(async () => {
+ const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
+ const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
+
+ if (docIds.length === 0 && folderIds.length === 0) {
+ return;
+ }
+
+ const parts = [];
+ if (docIds.length) {
+ parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`);
+ }
+ if (folderIds.length) {
+ parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`);
+ }
+ const descriptor = parts.join(' and ');
+ const confirmation = parts.length === 1
+ ? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`
+ : `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`;
+
+ if (!window.confirm(confirmation)) {
+ return;
+ }
+
+ setLoading(true);
+ let docsOk = true;
+ let foldersOk = true;
+
+ try {
+ if (docIds.length) {
+ docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false });
+ }
+
+ if (folderIds.length) {
+ for (const folderId of folderIds) {
+ const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false });
+ if (!success) {
+ foldersOk = false;
+ }
+ }
+ }
+ } finally {
+ setLoading(false);
+ }
+
+ if (!docsOk || !foldersOk) {
+ setStatusMessage('Some items could not be deleted. Ensure folders are empty before deletion.', 'error');
+ return;
+ }
+
+ clearDocumentSelection();
+
+ const successParts = [];
+ if (docIds.length) {
+ successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.');
+ }
+ if (folderIds.length) {
+ successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.');
+ }
+
+ setStatusMessage(successParts.join(' '), 'success');
+ }, [
+ selectedDocumentIds,
+ selectedFolderIds,
+ handleDocumentsDelete,
+ handleFolderDelete,
+ clearDocumentSelection,
+ setLoading,
+ setStatusMessage,
+ ]);
+
+ 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 handleFolderRename = useCallback(
+ async (folderId, nextName) => {
+ if (!token) {
+ setStatusMessage('Log in to rename folders.', 'error');
+ return false;
+ }
+ if (!folderId || folderId === 'root') {
+ setStatusMessage('The root folder cannot be renamed.', 'error');
+ return false;
+ }
+ const trimmed = typeof nextName === 'string' ? nextName.trim() : '';
+ if (!trimmed) {
+ setStatusMessage('Folder name cannot be empty.', 'error');
+ return false;
+ }
+
+ setLoading(true);
+ try {
+ await api.patch(`/folders/${folderId}`, { name: trimmed });
+
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ const node = next.get(folderId);
+ if (node) {
+ next.set(folderId, { ...node, name: trimmed });
+ }
+ return next;
+ });
+
+ setFolderContents((prev) => {
+ if (!prev.has(folderId)) {
+ return prev;
+ }
+ const next = new Map(prev);
+ const existing = next.get(folderId) || {};
+ const folderInfo = existing.folder
+ ? { ...existing.folder, name: trimmed }
+ : { id: folderId, name: trimmed };
+ next.set(folderId, { ...existing, folder: folderInfo });
+ return next;
+ });
+
+ setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev));
+
+ setStatusMessage('Folder renamed.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to rename folder.';
+ notifyApiError(error, message);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [token, notifyApiError, setStatusMessage, setFolderNodes, setFolderContents, setCurrentFolder],
+ );
+
+ const handleFolderCreate = useCallback(
+ async (name) => {
+ if (!token) {
+ setStatusMessage('Log in to create folders.', 'error');
+ return false;
+ }
+ if (!name.trim()) {
+ setStatusMessage('Folder name cannot be empty.', 'error');
+ return false;
+ }
+ const payload = {
+ name: name.trim(),
+ parent_id: selectedFolder === 'root' ? null : selectedFolder,
+ };
+ setLoading(true);
+ let succeeded = false;
+ try {
+ const { data } = await api.post('/folders', payload);
+ setStatusMessage('Folder created.', 'success');
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ const parentId = payload.parent_id || 'root';
+ const parentNode = next.get(parentId);
+ if (parentNode) {
+ next.set(parentId, {
+ ...parentNode,
+ children: parentNode.children.concat([data.folder.id]),
+ loaded: true,
+ hasChildren: true,
+ });
+ }
+ next.set(data.folder.id, {
+ id: data.folder.id,
+ name: data.folder.name,
+ parentId: parentId,
+ children: [],
+ expanded: false,
+ loaded: false,
+ hasChildren: false,
+ });
+ return next;
+ });
+ await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
+ await selectFolder(data.folder.id, { immediate: true });
+ succeeded = true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to create folder.';
+ notifyApiError(error, message);
+ succeeded = false;
+ } finally {
+ setLoading(false);
+ }
+ return succeeded;
+ },
+ [
+ token,
+ selectedFolder,
+ ensureFolderData,
+ notifyApiError,
+ setStatusMessage,
+ selectFolder,
+ setFolderNodes,
+ ],
+ );
+
+ 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 openSettings = useCallback(() => {
+ navigate('/settings');
+ }, [navigate]);
+
+ 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 folderClickHandlers = useMemo(
+ () => ({
+ onToggle: async (folderId) => {
+ const node = folderNodes.get(folderId);
+ const nextExpanded = !(node?.expanded ?? false);
+ if (nextExpanded) {
+ try {
+ await ensureFolderData(folderId, {
+ includeDocuments: false,
+ prefetchDepth: 1,
+ });
+ } catch (error) {
+ notifyApiError(error, 'Failed to load folder.');
+ }
+ } else if (node && !node.loaded) {
+ try {
+ await ensureFolderData(folderId, {
+ includeDocuments: false,
+ prefetchDepth: 1,
+ });
+ } catch (error) {
+ notifyApiError(error, 'Failed to load folder.');
+ }
+ }
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ const current = next.get(folderId);
+ if (!current) return prev;
+ next.set(folderId, { ...current, expanded: nextExpanded });
+ return next;
+ });
+ },
+ onSelect: selectFolder,
+ onDrop: async (event, folderId) => {
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.classList.remove('is-drop-target');
+
+ let folderIds = [];
+ try {
+ const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
+ if (rawFolderList) {
+ const parsed = JSON.parse(rawFolderList);
+ if (Array.isArray(parsed)) {
+ folderIds = parsed.filter(Boolean);
+ }
+ }
+ } catch (error) {
+ console.warn('[folders] Failed to parse folder list drag payload', error);
+ }
+
+ if (!folderIds.length) {
+ let folderSourceId = draggedFolderId;
+ if (!folderSourceId) {
+ try {
+ if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) {
+ folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder');
+ }
+ } catch (error) {
+ console.warn('[folders] Failed to read folder id from drag payload', error);
+ }
+ }
+
+ if (folderSourceId) {
+ folderIds = [folderSourceId];
+ }
+ }
+
+ folderIds = Array.from(new Set(folderIds.filter(Boolean)));
+
+ if (folderIds.length) {
+ setDraggedFolderId(null);
+ const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId));
+ if (invalidMove) {
+ setStatusMessage(
+ 'Cannot move a folder into itself or one of its descendants.',
+ 'error',
+ );
+ return;
+ }
+
+ for (const sourceId of folderIds) {
+ // eslint-disable-next-line no-await-in-loop
+ await moveFolder(sourceId, folderId);
+ }
+ }
+
+ if (hasFiles(event)) {
+ await handleFileDrop(event.dataTransfer, folderId);
+ return;
+ }
+
+ let docIds = [];
+ try {
+ const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ docIds = parsed.filter(Boolean);
+ }
+ }
+ } catch (error) {
+ console.warn('[documents] Failed to parse document list drag payload', error);
+ }
+
+ if (!docIds.length) {
+ try {
+ const single = event.dataTransfer.getData('application/x-papercrate-doc');
+ if (single) {
+ docIds = [single];
+ }
+ } catch (error) {
+ console.warn('[documents] Failed to read single document drag payload', error);
+ }
+ }
+
+ if (!docIds.length && draggedDocumentIds.length) {
+ docIds = draggedDocumentIds;
+ }
+
+ docIds = Array.from(new Set(docIds));
+
+ if (!docIds.length || folderId === selectedFolder) {
+ return;
+ }
+
+ setDraggedDocumentIds([]);
+ await moveDocumentsToFolder(docIds, folderId);
+ },
+ onDragOver: (event, folderId) => {
+ const folderDragActive = Boolean(draggedFolderId);
+ if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
+ return;
+ }
+
+ if (hasFiles(event)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ event.currentTarget.classList.add('is-drop-target');
+ return;
+ }
+
+ if (draggedDocumentIds.length || folderDragActive) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ event.currentTarget.classList.add('is-drop-target');
+ }
+ },
+ onDragLeave: (event) => {
+ event.currentTarget.classList.remove('is-drop-target');
+ },
+ }),
+ [
+ draggedDocumentIds,
+ draggedFolderId,
+ ensureFolderData,
+ folderNodes,
+ handleFileDrop,
+ isInvalidFolderDrop,
+ moveDocumentsToFolder,
+ moveFolder,
+ notifyApiError,
+ selectFolder,
+ selectedFolder,
+ setDraggedDocumentIds,
+ setDraggedFolderId,
+ setFolderNodes,
+ setStatusMessage,
+ ],
+ );
+
+ const selectedDocument = useMemo(() => {
+ if (!focusedDocumentId) {
+ return null;
+ }
+ const list = searchResults ?? documents;
+ return list.find((doc) => doc.id === focusedDocumentId) || null;
+ }, [searchResults, documents, focusedDocumentId]);
+
+
+ const orderedSelectedDocuments = useMemo(() => {
+ const ordered = [];
+ const seen = new Set();
+ const pushDoc = (doc) => {
+ if (doc?.id && !seen.has(doc.id)) {
+ ordered.push(doc);
+ seen.add(doc.id);
+ }
+ };
+
+ selectionOrder.forEach((key) => {
+ if (!isDocumentRowKey(key)) {
+ return;
+ }
+ const docId = getRowId(key);
+ const doc = documentLookup.get(docId) || null;
+ pushDoc(doc);
+ });
+
+ selectedDocumentIds.forEach((id) => {
+ if (seen.has(id)) return;
+ const doc = documentLookup.get(id) || null;
+ pushDoc(doc);
+ });
+
+ return ordered;
+ }, [selectionOrder, documentLookup, selectedDocumentIds]);
+
+ const {
+ detailPanelOpen,
+ detailPanelDocument,
+ openDetailPanel,
+ closeDetailPanel,
+ } = useDetailPanel({
+ documentLookup,
+ orderedSelectedDocuments,
+ });
+
+ detailPanelControlRef.current = {
+ open: openDetailPanel,
+ close: closeDetailPanel,
+ };
+
+ const handleEntryPointerCore = useEntryPointerCore({
+ resolveDocumentRowKey,
+ resolveFolderRowKey,
+ onSelectDocument: (documentId, event, { rowKey }) => {
+ const key = rowKey || resolveDocumentRowKey(documentId);
+ if (key) {
+ handleEntrySelection(key, event);
+ }
+ },
+ onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => {
+ const key = rowKey || resolveFolderRowKey(folderId);
+ if (key) {
+ handleEntrySelection(key, event);
+ }
+ if (!modifierClick && primaryClick) {
+ selectFolder(folderId);
+ }
+ },
+ });
+
+ const inspectDocument = useCallback(
+ (documentId) => {
+ if (!documentId) {
+ return;
+ }
+ openDetailPanel({ documentIds: [documentId] });
+ },
+ [openDetailPanel],
+ );
+
+ 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]);
+
+ useEffect(() => {
+ if (!orderedSelectedDocuments.length) {
+ return;
+ }
+
+ const visited = new Set();
+
+ orderedSelectedDocuments.forEach((doc) => {
+ const folderId = doc?.folder_id;
+ if (!folderId) {
+ return;
+ }
+ let currentId = folderId;
+ let guard = 0;
+ while (currentId && currentId !== 'root' && guard < 32) {
+ guard += 1;
+ if (visited.has(currentId)) {
+ break;
+ }
+ visited.add(currentId);
+ const node = folderNodes.get(currentId);
+ if (!node) {
+ if (!detailFolderFetchRef.current.has(currentId)) {
+ detailFolderFetchRef.current.add(currentId);
+ ensureFolderData(currentId, { force: false, includeDocuments: false })
+ .catch((error) => {
+ console.warn('Failed to preload folder metadata for detail path', currentId, error);
+ })
+ .finally(() => {
+ detailFolderFetchRef.current.delete(currentId);
+ });
+ }
+ break;
+ }
+
+ const parentId = node.parentId ?? 'root';
+ if (!parentId || parentId === 'root') {
+ break;
+ }
+ currentId = parentId;
+ }
+ });
+ }, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
+
+ const resolveFolderPath = useCallback(
+ (folderId) => {
+ if (!folderId || folderId === 'root') {
+ return [];
+ }
+
+ const segments = [];
+ const visited = new Set();
+ let currentId = folderId;
+ let guard = 0;
+
+ while (currentId && guard < 32 && !visited.has(currentId)) {
+ guard += 1;
+ visited.add(currentId);
+
+ if (currentId === 'root') {
+ break;
+ }
+
+ const node = folderNodes.get(currentId);
+ if (!node) {
+ segments.push({ id: currentId, name: '…' });
+ break;
+ }
+
+ segments.push({ id: node.id, name: node.name || 'Folder' });
+
+ const parentId = node.parentId ?? 'root';
+ if (!parentId || parentId === 'root') {
+ segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
+ break;
+ }
+
+ currentId = parentId;
+ }
+
+ if (!segments.some((segment) => segment.id === 'root')) {
+ segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
+ }
+
+ return segments.reverse();
+ },
+ [folderNodes],
+ );
+
+ const selectedPreviewEntry = useMemo(() => {
+ if (!selectedDocument) {
+ return null;
+ }
+ return previewEntries.get(selectedDocument.id) || null;
+ }, [selectedDocument, previewEntries]);
+
+ const previewWorkspaceEntry = useMemo(() => {
+ if (!previewDocumentId) {
+ return null;
+ }
+ return previewEntries.get(previewDocumentId) || null;
+ }, [previewDocumentId, previewEntries]);
+
+ const previewWorkspaceDocument = useMemo(() => {
+ if (!previewDocumentId) return null;
+ const pool = searchResults ?? documents;
+ return pool.find((doc) => doc.id === previewDocumentId) || null;
+ }, [previewDocumentId, searchResults, documents]);
+
+ const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
+
+ const resolveThumbnailUrlForDoc = useCallback(
+ (doc) =>
+ resolveDocumentAssetUrl(doc, 'thumbnail', {
+ ensureAssetUrl,
+ getAsset: getDocumentAsset,
+ }),
+ [ensureAssetUrl, getDocumentAsset],
+ );
+
+ const { handleTenantSelect } = useTenantManager({
+ apiClient: api,
+ appDispatch,
+ currentTenantId,
+ resetWorkspaceState,
+ setStatusMessage,
+ notifyApiError,
+ setLoading,
+ refreshTags,
+ refreshCorrespondents,
+ loadFolder,
+ handleDocumentsViewModeChange,
+ navigate,
+ tokenRef,
+ tenantIdRef,
+ });
+
+
+ const documentsTableProps = useMemo(
+ () => ({
+ currentFolderName,
+ breadcrumbs,
+ onRefresh: refreshCurrentFolder,
+ subfolders: currentSubfolders,
+ documents,
+ searchResults,
+ isFilterActive,
+ onFolderSelect: selectFolder,
+ onFolderDrop: folderClickHandlers.onDrop,
+ onFolderDragOver: folderClickHandlers.onDragOver,
+ onFolderDragLeave: folderClickHandlers.onDragLeave,
+ onFolderDragStart: handleFolderDragStart,
+ onFolderDragEnd: handleFolderDragEnd,
+ draggedFolderId,
+ onFolderRename: handleFolderRename,
+ onDocumentOpen: openDocumentPreview,
+ onDocumentRename: handleDocumentTitleUpdate,
+ selectedDocumentIds,
+ selectedFolderIds,
+ focusedRowKey,
+ draggingDocumentIds: draggedDocumentIds,
+ onDocumentDragStart: handleDocumentDragStart,
+ onDocumentDragEnd: handleDocumentDragEnd,
+ isSearchLoading: searchLoading,
+ tagLookupById,
+ activeCorrespondentIds: activeCorrespondentFilters,
+ selectedEntries,
+ onFocusedRowChange: setFocusedRowKey,
+ ensureAssetUrl,
+ getDocumentAsset,
+ onTagClick: toggleTagFilter,
+ onCorrespondentClick: toggleCorrespondentFilter,
+ onDocumentTagDrop: handleDocumentTagDrop,
+ viewMode: documentsViewMode,
+ sortField: documentsSortField,
+ sortDirection: documentsSortDirection,
+ onSortFieldChange: handleDocumentsSortFieldChange,
+ onSortDirectionToggle: handleDocumentsSortDirectionToggle,
+ searchIncludeDescendants,
+ onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
+ onViewModeChange: handleDocumentsViewModeChange,
+ onClearSelection: clearDocumentSelection,
+ onDeleteSelection: handleDeleteSelection,
+ onEntryPointer: handleEntryPointerCore,
+ onInspectDocument: inspectDocument,
+ onEntrySelection: handleEntrySelection,
+ tags,
+ correspondents,
+ documentLookup,
+ onBulkTagAdd: handleBulkTagAddFromDetail,
+ onBulkTagRemove: handleBulkTagRemoveFromDetail,
+ onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
+ onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
+ onBulkReanalyze: handleBulkSelectionReanalyze,
+ folderOptions,
+ onMoveDocumentsToFolder: moveDocumentsToFolder,
+ }),
+ [
+ activeCorrespondentFilters,
+ breadcrumbs,
+ clearDocumentSelection,
+ currentFolderName,
+ currentSubfolders,
+ documents,
+ documentsViewMode,
+ documentsSortField,
+ documentsSortDirection,
+ draggedDocumentIds,
+ draggedFolderId,
+ focusedRowKey,
+ folderClickHandlers,
+ handleDocumentDragEnd,
+ handleDocumentDragStart,
+ handleDocumentTagDrop,
+ handleDocumentTitleUpdate,
+ handleDocumentsViewModeChange,
+ handleDocumentsSortFieldChange,
+ handleDocumentsSortDirectionToggle,
+ handleFolderDragEnd,
+ handleFolderDragStart,
+ handleFolderRename,
+ handleEntryPointerCore,
+ inspectDocument,
+ handleEntrySelection,
+ handleDeleteSelection,
+ isFilterActive,
+ openDocumentPreview,
+ refreshCurrentFolder,
+ searchLoading,
+ searchResults,
+ searchIncludeDescendants,
+ selectFolder,
+ selectedDocumentIds,
+ selectedEntries,
+ selectedFolderIds,
+ setFocusedRowKey,
+ tagLookupById,
+ toggleCorrespondentFilter,
+ toggleTagFilter,
+ ensureAssetUrl,
+ getDocumentAsset,
+ tags,
+ correspondents,
+ documentLookup,
+ handleBulkTagAddFromDetail,
+ handleBulkTagRemoveFromDetail,
+ handleBulkCorrespondentAdd,
+ handleBulkCorrespondentRemove,
+ handleBulkSelectionReanalyze,
+ folderOptions,
+ moveDocumentsToFolder,
+ toggleSearchIncludeDescendants,
+ ],
+ );
+
+ const sidebarProps = useMemo(
+ () => ({
+ folderNodes,
+ onToggle: folderClickHandlers.onToggle,
+ onSelect: folderClickHandlers.onSelect,
+ onDrop: folderClickHandlers.onDrop,
+ onDragOver: folderClickHandlers.onDragOver,
+ onDragLeave: folderClickHandlers.onDragLeave,
+ onDeleteFolder: handleFolderDelete,
+ onRenameFolder: handleFolderRename,
+ selectedFolder,
+ onFolderDragStart: handleFolderDragStart,
+ onFolderDragEnd: handleFolderDragEnd,
+ draggedFolderId,
+ onCreateFolder: handlePromptCreateFolder,
+ creatingFolder,
+ tags,
+ untaggedFilterId: TAG_FILTER_UNTAGGED,
+ activeTagIds: activeTagFilters,
+ onToggleTagFilter: toggleTagFilter,
+ onCreateTag: (label) => handleTagCreate({ label }),
+ correspondents,
+ activeCorrespondentIds: activeCorrespondentFilters,
+ onToggleCorrespondentFilter: toggleCorrespondentFilter,
+ onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
+ appStatus,
+ loading,
+ previewActive,
+ searchQuery,
+ onSearchChange: handleSearchChange,
+ onSearchSubmit: handleSearchSubmit,
+ onSearchClear: clearFilters,
+ isFilterActive,
+ onLogout: handleLogout,
+ status,
+ tenantName,
+ tenants: tenantOptions,
+ activeTenantId: currentTenantId,
+ onSelectTenant: handleTenantSelect,
+ onOpenSettings: openSettings,
+ }),
+ [
+ activeCorrespondentFilters,
+ activeTagFilters,
+ appStatus,
+ clearFilters,
+ correspondents,
+ currentTenantId,
+ folderClickHandlers,
+ folderNodes,
+ handleFolderDragEnd,
+ handleFolderDragStart,
+ handleFolderDelete,
+ handleFolderRename,
+ handleLogout,
+ handleSearchChange,
+ handleSearchSubmit,
+ handleTenantSelect,
+ loading,
+ openSettings,
+ previewActive,
+ searchQuery,
+ draggedFolderId,
+ isFilterActive,
+ selectedFolder,
+ status,
+ tags,
+ tenantOptions,
+ tenantName,
+ toggleCorrespondentFilter,
+ toggleTagFilter,
+ handleTagCreate,
+ handleCorrespondentCreate,
+ handlePromptCreateFolder,
+ creatingFolder,
+ ],
+ );
+
+ const handleDetailPanelClose = useCallback(() => {
+ closeDetailPanel();
+ }, [closeDetailPanel]);
+
+ const detailPanelProps = useMemo(
+ () => ({
+ document: detailPanelDocument,
+ tags,
+ tagLookupById,
+ onTagAdd: handleDocumentTagAdd,
+ onTagRemove: handleTagRemove,
+ previewEntry: selectedPreviewEntry,
+ onOpenPreview: openDocumentPreview,
+ onPromoteSelection: promoteSelectionOrder,
+ activePreviewId,
+ onUpdateTitle: handleDocumentTitleUpdate,
+ onUpdateIssued: handleDocumentIssuedUpdate,
+ ensureAssetUrl,
+ getDocumentAsset,
+ ensurePreviewData,
+ correspondents,
+ onCorrespondentAdd: handleCorrespondentAdd,
+ onCorrespondentRemove: handleCorrespondentRemove,
+ resolveApiPath,
+ onFolderNavigate: selectFolder,
+ onClose: handleDetailPanelClose,
+ resolveFolderPath,
+ }),
+ [
+ activePreviewId,
+ correspondents,
+ detailPanelDocument,
+ ensureAssetUrl,
+ ensurePreviewData,
+ getDocumentAsset,
+ handleCorrespondentAdd,
+ handleCorrespondentRemove,
+ handleDetailPanelClose,
+ handleDocumentTitleUpdate,
+ handleDocumentIssuedUpdate,
+ handleDocumentTagAdd,
+ handleTagRemove,
+ openDocumentPreview,
+ promoteSelectionOrder,
+ resolveFolderPath,
+ selectFolder,
+ selectedPreviewEntry,
+ tags,
+ tagLookupById,
+ ],
+ );
+
+
+
+ const handleDeskDocumentStackSelect = useCallback(
+ (docIds) => {
+ if (!Array.isArray(docIds) || docIds.length === 0) {
+ return;
+ }
+
+ const rowKeys = docIds
+ .map((id) => resolveDocumentRowKey(id))
+ .filter(Boolean);
+
+ 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];
+
+ applySelection(nextKeys, {
+ anchor,
+ interactedKeys: rowKeys,
+ });
+ },
+ [applySelection, selectedEntries, selectionAnchorRef],
+ );
+
+ const handleDeskDocumentOpen = useCallback(
+ (docId, { useSelection = false } = {}) => {
+ const selectionDocIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
+ let targetIds = [];
+
+ if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) {
+ targetIds = selectionDocIds;
+ } else if (selectionDocIds.length) {
+ targetIds = selectionDocIds;
+ } else if (docId) {
+ targetIds = [docId];
+ }
+
+ if (!targetIds.length) {
+ return;
+ }
+
+ openDetailPanel({ documentIds: targetIds });
+ },
+ [openDetailPanel, selectedDocumentIds],
+ );
+
+ const handleDeskHelpOpen = useCallback(() => {
+ setDeskHelpOpenSafe(true);
+ }, [setDeskHelpOpenSafe]);
+
+ const handleDeskHelpClose = useCallback(() => {
+ setDeskHelpOpenSafe(false);
+ }, [setDeskHelpOpenSafe]);
+
+ 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 = useDeskWorkspaceProps({
+ documents,
+ searchResults,
+ breadcrumbs,
+ currentFolderName,
+ documentsViewMode,
+ handleDocumentsViewModeChange,
+ handleDeskExit: handleDeskExitSafe,
+ refreshCurrentFolder,
+ handleDeskDocumentOpen,
+ inspectDocument,
+ handleEntryPointerCore,
+ handleDeskDocumentStackSelect,
+ promoteSelectionOrder,
+ handleDeskHelpOpen,
+ deskHelpOpen,
+ handleDeskHelpClose,
+ currentTenantId,
+ deskViewId,
+ selectedDocumentIds,
+ selectedFolderIds,
+ clearDocumentSelection,
+ detailPanelOpen,
+ handleDetailPanelClose,
+ resolveThumbnailUrlForDoc,
+ handleDocumentTagDrop,
+ handleTagRemove,
+ ensureAssetUrl,
+ getDocumentAsset,
+ activeTagFilters,
+ handleDeleteSelection,
+ tags,
+ correspondents,
+ documentLookup,
+ tagLookupById,
+ handleBulkTagAddFromDetail,
+ handleBulkTagRemoveFromDetail,
+ handleBulkCorrespondentAdd,
+ handleBulkCorrespondentRemove,
+ handleBulkSelectionReanalyze,
+ folderOptions,
+ moveDocumentsToFolder,
+ searchIncludeDescendants,
+ toggleSearchIncludeDescendants,
+ });
+
+ 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,
+ previewWorkspaceEntry,
+ previewDocumentId,
+ closeDocumentPreview,
+ handleThumbnailRegeneration,
+ documentsTableProps,
+ detailPanelProps,
+ documentsViewMode,
+ deskWorkspaceProps,
+ ensurePreviewData,
+ ensureAssetUrl,
+ resolveApiPath,
+ getDocumentAsset,
+ notifyApiError,
+ openTagsModal,
+ openCorrespondentsModal,
+ openSettings,
+ detailPanelOpen,
+ openDetailPanel,
+ }),
+ [
+ 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,
+ previewWorkspaceEntry,
+ previewDocumentId,
+ closeDocumentPreview,
+ handleThumbnailRegeneration,
+ documentsTableProps,
+ detailPanelProps,
+ documentsViewMode,
+ deskWorkspaceProps,
+ ensurePreviewData,
+ ensureAssetUrl,
+ getDocumentAsset,
+ notifyApiError,
+ openTagsModal,
+ openCorrespondentsModal,
+ openSettings,
+ detailPanelOpen,
+ openDetailPanel,
+ ],
+ );
+
+ // hook callers handle rendering / routing
+ return {
+ appStatus,
+ location,
+ shellRef,
+ dropOverlayState,
+ managementModals,
+ contextValue,
+ };
+};
+
+
+export default useDocumentsWorkspace;
diff --git a/frontend/src/hooks/documents/useFileDrop.js b/frontend/src/hooks/documents/useFileDrop.js
new file mode 100644
index 0000000..aaa9e9e
--- /dev/null
+++ b/frontend/src/hooks/documents/useFileDrop.js
@@ -0,0 +1,84 @@
+import { useEffect } from 'react';
+
+const useFileDrop = ({
+ shellRef,
+ token,
+ currentFolderName,
+ selectedFolder,
+ handleFileDrop,
+ hasFiles,
+ defaultFolderName,
+ dragCounterRef,
+ setDropOverlayState,
+}) => {
+
+ useEffect(() => {
+ if (!token) {
+ setDropOverlayState((prev) => ({ ...prev, active: false }));
+ dragCounterRef.current = 0;
+ return undefined;
+ }
+
+ const handleDragEnter = (event) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ dragCounterRef.current += 1;
+ setDropOverlayState({ active: true, folderName: currentFolderName });
+ };
+
+ const handleDragOver = (event) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ };
+
+ const handleDragLeave = (event) => {
+ if (!hasFiles(event)) return;
+ dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
+ if (dragCounterRef.current === 0) {
+ setDropOverlayState((prev) => ({ ...prev, active: false }));
+ }
+ };
+
+ const handleDrop = async (event) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ dragCounterRef.current = 0;
+ setDropOverlayState((prev) => ({ ...prev, active: false }));
+ await handleFileDrop(event.dataTransfer, selectedFolder);
+ };
+
+ const dropTarget = shellRef.current;
+ if (!dropTarget) {
+ return undefined;
+ }
+
+ dropTarget.addEventListener('dragenter', handleDragEnter);
+ dropTarget.addEventListener('dragover', handleDragOver);
+ dropTarget.addEventListener('dragleave', handleDragLeave);
+ dropTarget.addEventListener('drop', handleDrop);
+
+ return () => {
+ dropTarget.removeEventListener('dragenter', handleDragEnter);
+ dropTarget.removeEventListener('dragover', handleDragOver);
+ dropTarget.removeEventListener('dragleave', handleDragLeave);
+ dropTarget.removeEventListener('drop', handleDrop);
+ dragCounterRef.current = 0;
+ setDropOverlayState((prev) => ({ ...prev, active: false }));
+ };
+ }, [
+ token,
+ handleFileDrop,
+ currentFolderName,
+ defaultFolderName,
+ selectedFolder,
+ hasFiles,
+ shellRef,
+ dragCounterRef,
+ setDropOverlayState,
+ ]);
+
+ return null;
+};
+
+export default useFileDrop;
diff --git a/frontend/src/hooks/documents/useFolderTree.js b/frontend/src/hooks/documents/useFolderTree.js
new file mode 100644
index 0000000..ce5f369
--- /dev/null
+++ b/frontend/src/hooks/documents/useFolderTree.js
@@ -0,0 +1,432 @@
+import { useCallback, useMemo, useState } from 'react';
+import {
+ DEFAULT_FOLDER_NAME,
+ createRootNode,
+ getRowId,
+ isDocumentRowKey,
+ isFolderRowKey,
+ resolveDocumentRowKey,
+ resolveFolderRowKey,
+} from '../../app/appLayoutUtils';
+
+const useFolderTree = ({
+ initialSelectedFolder = 'root',
+ assetManager,
+ apiClient,
+ tenantIdRef,
+ documentsSortFieldRef,
+ documentsSortDirectionRef,
+ selectionHelpers,
+ setDocuments,
+ setFolderContents,
+ folderContentsRef,
+}) => {
+ const [folderNodes, setFolderNodes] = useState(() => {
+ const rootNode = createRootNode();
+ return new Map([[rootNode.id, rootNode]]);
+ });
+
+ const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root');
+ const [currentFolder, setCurrentFolder] = useState(null);
+ const [currentSubfolders, setCurrentSubfolders] = useState([]);
+
+ const {
+ focusedDocumentId,
+ setFocusedDocumentId,
+ setSelectedEntries,
+ setSelectionOrder,
+ selectionOrderRef,
+ selectionAnchorRef,
+ } = selectionHelpers;
+
+ const applySelectedFolder = useCallback(
+ (folderId, contents) => {
+ const subfolders = contents?.subfolders ?? [];
+ const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
+ const folderInfo = contents?.folder ?? null;
+
+ setCurrentSubfolders(subfolders);
+ setDocuments(docs);
+ setCurrentFolder(folderInfo);
+
+ const availableDocKeys = docs
+ .map((doc) => resolveDocumentRowKey(doc.id))
+ .filter(Boolean);
+ const availableDocKeySet = new Set(availableDocKeys);
+ const availableFolderKeys = new Set(
+ subfolders
+ .map((folder) => resolveFolderRowKey(folder.id))
+ .filter(Boolean),
+ );
+
+ let nextDocKeys = [];
+ let mergedSelection = [];
+
+ setSelectedEntries((previous) => {
+ const previousFolderKeys = previous
+ .filter(isFolderRowKey)
+ .filter((key) => availableFolderKeys.has(key));
+ const previousDocKeys = previous.filter(isDocumentRowKey);
+ nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
+ mergedSelection = [...previousFolderKeys, ...nextDocKeys];
+ return mergedSelection;
+ });
+
+ const nextFocus = (() => {
+ const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId);
+ if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
+ return focusedDocumentId;
+ }
+ if (nextDocKeys.length) {
+ const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
+ return getRowId(lastDocKey) || null;
+ }
+ return null;
+ })();
+
+ setFocusedDocumentId(nextFocus);
+ selectionAnchorRef.current = nextDocKeys.length
+ ? nextDocKeys[nextDocKeys.length - 1]
+ : null;
+ selectionOrderRef.current = mergedSelection;
+ setSelectionOrder(mergedSelection);
+ },
+ [
+ assetManager,
+ focusedDocumentId,
+ selectionAnchorRef,
+ selectionOrderRef,
+ setDocuments,
+ setFocusedDocumentId,
+ setSelectedEntries,
+ setSelectionOrder,
+ ],
+ );
+
+ const expandFolderAncestors = useCallback((targetId) => {
+ if (!targetId || targetId === 'root') {
+ setFolderNodes((prev) => {
+ const root = prev.get('root');
+ if (root?.expanded) return prev;
+ const next = new Map(prev);
+ next.set('root', { ...root, expanded: true });
+ return next;
+ });
+ return;
+ }
+
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ let currentId = targetId;
+ let guard = 0;
+ while (currentId && guard < 32) {
+ guard += 1;
+ const node = next.get(currentId);
+ if (!node) break;
+ if (!node.expanded) {
+ next.set(currentId, { ...node, expanded: true });
+ }
+ currentId = node.parentId ?? 'root';
+ }
+ return next;
+ });
+ }, []);
+
+ const ensureFolderData = useCallback(
+ async (
+ folderId,
+ {
+ includeDocuments = true,
+ prefetchDepth = 0,
+ force = false,
+ sortField = documentsSortFieldRef.current,
+ sortDirection = documentsSortDirectionRef.current,
+ } = {},
+ ) => {
+ const requestTenantId = tenantIdRef.current;
+ const cached = folderContentsRef.current.get(folderId);
+ const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
+ const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
+ const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
+
+ if (!force && cached) {
+ const includesDocuments = Boolean(cached.__includesDocuments);
+ if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
+ if (prefetchDepth > 0) {
+ const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
+ await Promise.allSettled(
+ subfolders.map((entry) =>
+ ensureFolderData(entry.id, {
+ includeDocuments: false,
+ prefetchDepth: prefetchDepth - 1,
+ force: false,
+ }),
+ ),
+ );
+ }
+ return cached;
+ }
+ }
+
+ const path = folderId === 'root' ? 'root' : folderId;
+ const params = {};
+ if (!includeDocuments) {
+ params.include_documents = false;
+ } else {
+ params.sort = sortField;
+ params.dir = sortDirection;
+ }
+ const requestConfig = Object.keys(params).length ? { params } : {};
+ const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig);
+ const hydrated = assetManager.hydrateFolderContents(data);
+ const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
+ const childIds = childFolders.map((child) => child.id);
+
+ const enriched = {
+ ...hydrated,
+ __includesDocuments: includeDocuments,
+ __sortField: includeDocuments ? sortField : cachedSortField,
+ __sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
+ };
+
+ if (tenantIdRef.current !== requestTenantId) {
+ return enriched;
+ }
+
+ setFolderNodes((prev) => {
+ const next = new Map(prev);
+ const existingNode = next.get(folderId) || {
+ id: folderId,
+ name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
+ parentId: data.folder?.parent_id || 'root',
+ children: [],
+ expanded: folderId === 'root',
+ loaded: false,
+ hasChildren: false,
+ };
+
+ next.set(folderId, {
+ ...existingNode,
+ name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
+ parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
+ children: childIds,
+ expanded: folderId === 'root' ? true : existingNode.expanded,
+ loaded: true,
+ hasChildren: childIds.length > 0,
+ });
+
+ childFolders.forEach((child) => {
+ const childNode = next.get(child.id);
+ const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
+ const childHasChildren = (() => {
+ if (childNode?.loaded) {
+ return previousChildren.length > 0;
+ }
+ if (Array.isArray(child?.subfolders)) {
+ return child.subfolders.length > 0;
+ }
+ if (typeof child?.has_children === 'boolean') {
+ return child.has_children;
+ }
+ if (typeof child?.hasChildren === 'boolean') {
+ return child.hasChildren;
+ }
+ if (typeof childNode?.hasChildren === 'boolean') {
+ return childNode.hasChildren;
+ }
+ return false;
+ })();
+ next.set(child.id, {
+ id: child.id,
+ name: child.name,
+ parentId: child.parent_id ?? 'root',
+ children: previousChildren,
+ expanded: childNode?.expanded ?? false,
+ loaded: childNode?.loaded ?? false,
+ hasChildren: childHasChildren,
+ });
+ });
+
+ return next;
+ });
+
+ if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
+ await Promise.allSettled(
+ childIds.map((childId) =>
+ ensureFolderData(childId, {
+ includeDocuments: false,
+ force: false,
+ prefetchDepth: prefetchDepth - 1,
+ }),
+ ),
+ );
+ }
+
+ setFolderContents((prev) => {
+ if (tenantIdRef.current !== requestTenantId) {
+ return prev;
+ }
+ const next = new Map(prev);
+ if (includeDocuments) {
+ next.set(folderId, enriched);
+ } else {
+ const existingEntry = next.get(folderId);
+ if (existingEntry) {
+ next.set(folderId, {
+ ...existingEntry,
+ ...hydrated,
+ documents: existingEntry.__includesDocuments
+ ? existingEntry.documents
+ : hydrated.documents,
+ __includesDocuments: existingEntry.__includesDocuments || false,
+ __sortField: existingEntry.__sortField ?? enriched.__sortField,
+ __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
+ });
+ } else {
+ next.set(folderId, enriched);
+ }
+ }
+ return next;
+ });
+
+ return enriched;
+ },
+ [
+ apiClient,
+ assetManager,
+ documentsSortDirectionRef,
+ documentsSortFieldRef,
+ tenantIdRef,
+ setFolderContents,
+ folderContentsRef,
+ ],
+ );
+
+ const ensureFolderAncestorsLoaded = useCallback(
+ async (targetId) => {
+ if (!targetId || targetId === 'root') {
+ return;
+ }
+ let current = targetId;
+ let guard = 0;
+ while (current && current !== 'root' && guard < 32) {
+ guard += 1;
+ const node = folderNodes.get(current);
+ if (node?.loaded) {
+ current = node.parentId ?? 'root';
+ continue;
+ }
+ await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 });
+ current = folderNodes.get(current)?.parentId ?? 'root';
+ }
+ },
+ [folderNodes, ensureFolderData],
+ );
+
+ const isInvalidFolderDrop = useCallback(
+ (sourceId, targetId) => {
+ if (!sourceId) return false;
+ if (!targetId || targetId === 'root') {
+ return false;
+ }
+ if (sourceId === targetId) {
+ return true;
+ }
+
+ let current = targetId;
+ const visited = new Set();
+ while (current && current !== 'root' && !visited.has(current)) {
+ visited.add(current);
+ if (current === sourceId) {
+ return true;
+ }
+ const node = folderNodes.get(current);
+ if (!node) break;
+ current = node.parentId ?? 'root';
+ }
+ return false;
+ },
+ [folderNodes],
+ );
+
+ const resetFolderTreeState = useCallback(() => {
+ const rootNode = createRootNode();
+ setFolderNodes(new Map([[rootNode.id, rootNode]]));
+ setFolderContents(new Map());
+ setSelectedFolder('root');
+ setCurrentFolder(null);
+ setCurrentSubfolders([]);
+ }, [setFolderContents]);
+
+ const currentFolderName = useMemo(() => {
+ if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
+ return currentFolder.name;
+ }, [selectedFolder, currentFolder]);
+
+ const folderOptions = useMemo(() => {
+ const cache = new Map();
+ const computePath = (id) => {
+ if (cache.has(id)) {
+ return cache.get(id);
+ }
+ if (!id || id === 'root') {
+ cache.set('root', DEFAULT_FOLDER_NAME);
+ return DEFAULT_FOLDER_NAME;
+ }
+ const node = folderNodes.get(id);
+ if (!node) {
+ return 'Folder';
+ }
+ const parentId = node.parentId || 'root';
+ const parentPath = computePath(parentId);
+ const name = node.name || 'Folder';
+ const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
+ cache.set(id, fullPath);
+ return fullPath;
+ };
+
+ const entries = [];
+ folderNodes.forEach((node, id) => {
+ if (!node) return;
+ entries.push({ id, label: computePath(id) });
+ });
+
+ entries.sort((a, b) => {
+ if (a.id === 'root') return -1;
+ if (b.id === 'root') return 1;
+ return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
+ });
+
+ return entries;
+ }, [folderNodes]);
+
+ const folderLabelMap = useMemo(() => {
+ const map = new Map();
+ folderOptions.forEach((option) => {
+ map.set(option.id, option.label);
+ });
+ return map;
+ }, [folderOptions]);
+
+ return {
+ folderNodes,
+ setFolderNodes,
+ selectedFolder,
+ setSelectedFolder,
+ currentFolder,
+ setCurrentFolder,
+ currentSubfolders,
+ setCurrentSubfolders,
+ currentFolderName,
+ folderOptions,
+ folderLabelMap,
+ applySelectedFolder,
+ ensureFolderData,
+ ensureFolderAncestorsLoaded,
+ expandFolderAncestors,
+ isInvalidFolderDrop,
+ resetFolderTreeState,
+ };
+};
+
+export default useFolderTree;
diff --git a/frontend/src/hooks/documents/useTags.js b/frontend/src/hooks/documents/useTags.js
new file mode 100644
index 0000000..0f54b42
--- /dev/null
+++ b/frontend/src/hooks/documents/useTags.js
@@ -0,0 +1,125 @@
+import { useCallback, useState } from 'react';
+
+const useTags = ({
+ apiClient,
+ notifyApiError,
+ setStatusMessage,
+ tagManager,
+ tenantIdRef,
+ setActiveTagFilters,
+ mapDocumentCaches,
+}) => {
+ const [tags, setTags] = useState([]);
+
+ const refreshTags = useCallback(async () => {
+ const requestTenantId = tenantIdRef.current;
+ try {
+ const { data } = await apiClient.get('/tags');
+ if (tenantIdRef.current !== requestTenantId) {
+ return;
+ }
+ setTags(data || []);
+ } catch (error) {
+ if (tenantIdRef.current !== requestTenantId) {
+ return;
+ }
+ notifyApiError(error, 'Unable to load tags.');
+ }
+ }, [apiClient, notifyApiError, tenantIdRef]);
+
+ const handleTagUpdate = useCallback(
+ async (tagId, changes) => {
+ if (!tagId) {
+ throw new Error('Missing tag identifier.');
+ }
+
+ const payload = {};
+ if (typeof changes.label === 'string') {
+ payload.label = changes.label;
+ }
+ if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
+ payload.color = changes.color;
+ }
+
+ if (Object.keys(payload).length === 0) {
+ return false;
+ }
+
+ try {
+ await apiClient.patch(`/tags/${tagId}`, payload);
+ await refreshTags();
+ setStatusMessage('Tag updated.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to update tag.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, notifyApiError, refreshTags, setStatusMessage],
+ );
+
+ const handleTagCreate = useCallback(
+ async ({ label, color } = {}) => {
+ const payload = tagManager.buildPayload({ label, color });
+ try {
+ await apiClient.post('/tags', payload);
+ await refreshTags();
+ setStatusMessage('Tag created.', 'success');
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to create tag.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, notifyApiError, refreshTags, setStatusMessage, tagManager],
+ );
+
+ const handleTagDelete = useCallback(
+ async (tagId) => {
+ if (!tagId) {
+ throw new Error('Missing tag identifier.');
+ }
+
+ try {
+ await apiClient.delete(`/tags/${tagId}`);
+ setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
+
+ const stripTagFromDoc = (doc) => {
+ if (!doc || !Array.isArray(doc.tags)) {
+ return doc;
+ }
+ const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
+ if (nextTags.length === doc.tags.length) {
+ return doc;
+ }
+ return { ...doc, tags: nextTags };
+ };
+
+ if (typeof mapDocumentCaches === 'function') {
+ mapDocumentCaches(stripTagFromDoc);
+ }
+
+ await refreshTags();
+ setStatusMessage('Tag deleted.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to delete tag.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [apiClient, mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, setStatusMessage],
+ );
+
+ return {
+ tags,
+ refreshTags,
+ handleTagUpdate,
+ handleTagCreate,
+ handleTagDelete,
+ setTags,
+ };
+};
+
+export default useTags;
diff --git a/frontend/src/hooks/documents/useTenantManager.js b/frontend/src/hooks/documents/useTenantManager.js
new file mode 100644
index 0000000..8dce9ed
--- /dev/null
+++ b/frontend/src/hooks/documents/useTenantManager.js
@@ -0,0 +1,104 @@
+import { useCallback } from 'react';
+
+const useTenantManager = ({
+ apiClient,
+ appDispatch,
+ currentTenantId,
+ resetWorkspaceState,
+ setStatusMessage,
+ notifyApiError,
+ setLoading,
+ refreshTags,
+ refreshCorrespondents,
+ loadFolder,
+ handleDocumentsViewModeChange,
+ navigate,
+ tokenRef,
+ tenantIdRef,
+}) => {
+ const handleTenantSelect = useCallback(
+ async (tenantOption, { refreshOnly = false } = {}) => {
+ const requestedTenantId = tenantOption?.id ?? null;
+ if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
+ return;
+ }
+
+ setLoading(true);
+ try {
+ if (!refreshOnly) {
+ setStatusMessage('Switching tenant…', 'info');
+ }
+
+ if (refreshOnly) {
+ const { data } = await apiClient.get('/auth/tenants');
+ appDispatch({
+ type: 'SET_TENANTS',
+ tenants: Array.isArray(data?.tenants) ? data.tenants : [],
+ });
+ return;
+ }
+
+ const { data } = await apiClient.post('/auth/select-tenant', {
+ tenant_id: requestedTenantId,
+ });
+ if (!data?.access_token) {
+ throw new Error('Missing access token in tenant switch response.');
+ }
+
+ appDispatch({ type: 'LOGOUT' });
+ resetWorkspaceState();
+
+ appDispatch({
+ type: 'LOGIN_SUCCESS',
+ token: data.access_token,
+ tenant: data.tenant || null,
+ });
+
+ apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
+ if (tokenRef) {
+ tokenRef.current = data.access_token;
+ }
+ if (tenantIdRef) {
+ tenantIdRef.current = data?.tenant?.id ?? null;
+ }
+
+ if (Array.isArray(data?.tenants)) {
+ appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
+ }
+
+ handleDocumentsViewModeChange('list');
+ navigate('/documents', { replace: true });
+
+ await Promise.all([refreshTags(), refreshCorrespondents()]);
+ await loadFolder('root', { showLoading: false, preserveSearch: false });
+
+ const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
+ setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
+ } catch (error) {
+ notifyApiError(error, 'Failed to switch tenant.');
+ } finally {
+ setLoading(false);
+ }
+ },
+ [
+ apiClient,
+ appDispatch,
+ currentTenantId,
+ handleDocumentsViewModeChange,
+ loadFolder,
+ navigate,
+ notifyApiError,
+ refreshCorrespondents,
+ refreshTags,
+ resetWorkspaceState,
+ setLoading,
+ setStatusMessage,
+ tenantIdRef,
+ tokenRef,
+ ],
+ );
+
+ return { handleTenantSelect };
+};
+
+export default useTenantManager;
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
new file mode 100644
index 0000000..13579c9
--- /dev/null
+++ b/frontend/src/lib/api.js
@@ -0,0 +1,8 @@
+import axios from 'axios';
+
+const api = axios.create({
+ baseURL: '/api',
+ withCredentials: true,
+});
+
+export default api;
diff --git a/frontend/src/settings/sections/ApiTokensSection.jsx b/frontend/src/settings/sections/ApiTokensSection.jsx
index 57bec67..fd65511 100644
--- a/frontend/src/settings/sections/ApiTokensSection.jsx
+++ b/frontend/src/settings/sections/ApiTokensSection.jsx
@@ -39,6 +39,10 @@ const ApiTokensSection = ({
const [newTokenExpires, setNewTokenExpires] = useState('');
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
const [formError, setFormError] = useState(null);
+ const supportsClipboardWrite = typeof navigator !== 'undefined'
+ && Boolean(navigator?.clipboard?.writeText);
+ const [canCopyToken, setCanCopyToken] = useState(supportsClipboardWrite);
+ const [copyFeedback, setCopyFeedback] = useState(null);
const capabilitySetOptions = useMemo(
() => capabilitySets.map((set) => ({
@@ -110,19 +114,44 @@ const ApiTokensSection = ({
onRefreshCapabilities?.();
}, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]);
- const handleCopyToken = useCallback(() => {
- if (!createdToken) {
+ const handleCopyToken = useCallback(async () => {
+ if (!createdToken || !canCopyToken) {
return;
}
- if (navigator?.clipboard?.writeText) {
- navigator.clipboard.writeText(createdToken).catch(() => {});
+
+ const showSuccess = () =>
+ setCopyFeedback({ type: 'success', message: 'Token copied to clipboard.' });
+ const showFailure = () =>
+ setCopyFeedback({
+ type: 'error',
+ message:
+ 'Copy failed. Your browser may require HTTPS access; please select the token manually.',
+ });
+
+ try {
+ await navigator.clipboard.writeText(createdToken);
+ showSuccess();
+ return;
+ } catch (error) {
+ // Some browsers expose writeText but still reject outside secure context
+ setCanCopyToken(false);
}
- }, [createdToken]);
+
+ showFailure();
+ }, [createdToken, canCopyToken]);
const handleDismissSecret = useCallback(() => {
+ setCopyFeedback(null);
onDismissCreatedToken?.();
}, [onDismissCreatedToken]);
+ useEffect(() => {
+ setCopyFeedback(null);
+ if (typeof navigator !== 'undefined') {
+ setCanCopyToken(Boolean(navigator?.clipboard?.writeText));
+ }
+ }, [createdToken]);
+
const handleNewCapabilitySetChange = useCallback((event) => {
setFormError(null);
setNewTokenCapabilitySetId(event.target.value);
@@ -215,13 +244,20 @@ const ApiTokensSection = ({
{createdToken}
-
+ {canCopyToken ? (
+
+ ) : null}
+ {copyFeedback ? (
+
+ {copyFeedback.message}
+
+ ) : null}
) : null}