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}