From 722c2f9a5c557ad48af10441fbab54595ddf834c Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 31 Oct 2025 01:26:24 +0100 Subject: [PATCH] tests --- backend/tests/auth_flow.rs | 6 +- backend/tests/common/mod.rs | 56 + backend/tests/correspondents_flow.rs | 3 +- backend/tests/documents_flow.rs | 37 +- frontend/src/DesktopWorkspace.css | 8 +- frontend/src/app/AppLayout.jsx | 5636 +++++++++++++++++++++++ frontend/src/app/AppRouter.jsx | 20 + frontend/src/app/DocumentsLayout.jsx | 11 + frontend/src/app/DocumentsRoute.jsx | 204 + frontend/src/app/DropOverlay.jsx | 11 + frontend/src/app/LoginRoute.jsx | 278 ++ frontend/src/app/appState.js | 240 + frontend/src/index.jsx | 6360 +------------------------- frontend/src/styles.css | 4 +- 14 files changed, 6497 insertions(+), 6377 deletions(-) create mode 100644 frontend/src/app/AppLayout.jsx create mode 100644 frontend/src/app/AppRouter.jsx create mode 100644 frontend/src/app/DocumentsLayout.jsx create mode 100644 frontend/src/app/DocumentsRoute.jsx create mode 100644 frontend/src/app/DropOverlay.jsx create mode 100644 frontend/src/app/LoginRoute.jsx create mode 100644 frontend/src/app/appState.js diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 796d3cc..9194032 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -53,8 +53,10 @@ struct SignupStartResponse { #[derive(Deserialize)] struct TenantSelectionResponse { - access_token: String, - tenants: Vec, + #[serde(rename = "access_token")] + _access_token: String, + #[serde(rename = "tenants")] + _tenants: Vec, } #[derive(Deserialize)] diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 61b6fee..6bbcd0c 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -693,6 +693,62 @@ pub async fn body_to_vec(body: Body) -> Result> { Ok(collected.to_bytes().to_vec()) } +#[cfg(test)] +mod helper_tests { + use super::*; + + #[tokio::test] + async fn create_session_and_login_token_provide_access() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "helper-login"; + let password = "irrelevant"; + app.insert_user(username, password, "admin").await?; + + let (access, refresh, refresh_id) = app.create_session(username).await?; + assert!(!access.is_empty(), "access token should not be empty"); + assert!(!refresh.is_empty(), "refresh token should not be empty"); + assert_ne!(refresh_id, Uuid::nil(), "refresh token id should be assigned"); + + let bearer = app.login_token(username, password).await?; + assert!(!bearer.is_empty(), "login_token must yield bearer"); + + app.cleanup().await?; + Ok(()) + } + + #[tokio::test] + async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + let username = "helper-passkey"; + let password = "unused"; + let user_id = app.insert_user(username, password, "admin").await?; + + let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?; + assert_ne!(passkey_id, Uuid::nil()); + + let bearer = app.login_token(username, password).await?; + let response = app + .upload_document_with_options( + "/api/documents", + "helper.txt", + "text/plain", + b"helper-content", + None, + Some("Helper Note"), + Some("{\"category\":\"note\"}"), + &bearer, + ) + .await?; + assert!(response.status().is_success()); + + app.cleanup().await?; + Ok(()) + } +} + async fn prepare_database(pool: &PgPool) -> Result<()> { let pool = pool.clone(); tokio::task::spawn_blocking(move || -> Result<()> { diff --git a/backend/tests/correspondents_flow.rs b/backend/tests/correspondents_flow.rs index 887a9b9..ae96e19 100644 --- a/backend/tests/correspondents_flow.rs +++ b/backend/tests/correspondents_flow.rs @@ -14,7 +14,8 @@ struct DocumentDetail { #[derive(Deserialize)] struct DocumentSummary { id: Uuid, - title: String, + #[serde(rename = "title")] + _title: String, #[serde(default)] correspondents: Vec, } diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 56230e5..829f899 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -25,12 +25,8 @@ struct DocumentInfo { metadata: Value, #[serde(default)] document_type_id: Option, - #[serde(default)] - document_type: Option, tags: Vec, #[serde(default)] - correspondents: Vec, - #[serde(default)] current_version: Option, } @@ -63,8 +59,6 @@ struct DocumentListItem { #[serde(default)] document_type_id: Option, #[serde(default)] - document_type: Option, - #[serde(default)] current_version: Option, } @@ -89,12 +83,6 @@ struct TagSummary { label: String, } -#[derive(Deserialize)] -struct DocumentCorrespondentInfo { - id: Uuid, - name: String, -} - #[derive(Deserialize)] struct DocumentTypeInfo { id: Uuid, @@ -196,6 +184,7 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(detail.document.title, "doc"); assert_eq!(detail.document.deleted_at, None); assert!(detail.document.issued_at.is_none()); + assert!(detail.document.document_type_id.is_none()); assert!(detail.document.tags.is_empty()); let current_version = detail .document @@ -1490,6 +1479,8 @@ async fn list_documents_filtered_by_document_type() -> Result<()> { assert_eq!(receipts_resp.status(), StatusCode::CREATED); let receipts_body = body_to_vec(receipts_resp.into_body()).await?; let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?; + assert_eq!(invoices.name, "Invoices"); + assert_eq!(receipts.name, "Receipts"); let doc_a = app .upload_document( @@ -1591,6 +1582,28 @@ async fn list_documents_filtered_by_document_type() -> Result<()> { assert!(combined_ids.contains(&detail_b.document.id)); assert!(!combined_ids.contains(&detail_c.document.id)); + let refreshed_a = app + .get( + &format!("/api/documents/{}", detail_a.document.id), + Some(&token), + ) + .await?; + assert!(refreshed_a.status().is_success()); + let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?; + let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?; + assert_eq!(refreshed_a_detail.document.document_type_id, Some(invoices.id)); + + let refreshed_b = app + .get( + &format!("/api/documents/{}", detail_b.document.id), + Some(&token), + ) + .await?; + assert!(refreshed_b.status().is_success()); + let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?; + let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?; + assert_eq!(refreshed_b_detail.document.document_type_id, Some(receipts.id)); + app.cleanup().await?; Ok(()) } diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/DesktopWorkspace.css index 8604524..63f4fb1 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/DesktopWorkspace.css @@ -146,14 +146,14 @@ padding: 0.25em; border-radius: 50%; border: none; - background: oklch(0.22 0.06 260deg); - color: var(--on-accent); + background: var(--preview-nav-bg); + color: var(--preview-nav-fg); cursor: pointer; transition: background 0.15s ease, opacity 0.15s ease; } .skeuo-card__nav-button:hover:not([disabled]) { - background: oklch(0.18 0.06 260deg); + background: var(--preview-nav-bg-hover); } .skeuo-card__nav-button:disabled { @@ -162,7 +162,7 @@ } .skeuo-card__nav-button:focus-visible { - outline: 2px solid var(--accent, #2684ff); + outline: 2px solid var(--accent); outline-offset: 2px; } diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx new file mode 100644 index 0000000..bc355e4 --- /dev/null +++ b/frontend/src/app/AppLayout.jsx @@ -0,0 +1,5636 @@ +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 TagsPanel from '../tags/TagsPanel'; +import CorrespondentsPanel from '../correspondents/CorrespondentsPanel'; +import DocumentTypesPanel from '../documentTypes/DocumentTypesPanel'; +import TagManager from '../tag_manager'; +import SettingsModal from '../settings/SettingsModal'; +import usePasskeys from '../settings/usePasskeys'; +import { AppShellContext } from '../appShellContext'; +import DropOverlay from './DropOverlay'; +import { api, useAppDispatch, useAppState } from './appState'; + +const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early +const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; + +const DEFAULT_FOLDER_NAME = 'All Documents'; + +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 [isTagsModalOpen, setTagsModalOpen] = useState(false); + const [isCorrespondentsModalOpen, setCorrespondentsModalOpen] = useState(false); + const [isDocumentTypesModalOpen, setDocumentTypesModalOpen] = useState(false); + const [isSettingsModalOpen, setSettingsModalOpen] = 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 [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root'); + const [currentFolder, setCurrentFolder] = useState(null); + const [currentSubfolders, setCurrentSubfolders] = useState([]); + const [documents, setDocuments] = useState([]); + const [workspaceMode, setWorkspaceMode] = useState('table'); + const [documentsViewMode, setDocumentsViewMode] = useState(() => { + if (typeof window === 'undefined') { + return 'list'; + } + const stored = window.localStorage.getItem('papercrate_view_mode'); + return stored === 'grid' ? 'grid' : 'list'; + }); + const [neutralHue, setNeutralHue] = useState(() => { + if (typeof window === 'undefined') { + return 260; + } + const stored = window.localStorage.getItem('papercrate_neutral_hue'); + if (stored) { + const parsedStored = Number.parseInt(stored, 10); + if (!Number.isNaN(parsedStored)) { + return parsedStored; + } + } + const root = document.documentElement; + const current = root.style.getPropertyValue('--neutral-hue'); + const parsed = Number.parseInt(current, 10); + return Number.isNaN(parsed) ? 260 : parsed; + }); + const initialRowSelection = []; + const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); + const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); + const [focusedDocumentId, setFocusedDocumentId] = useState(null); + const [focusedRowKey, setFocusedRowKey] = useState(null); + const tokenRef = useRef(token); + const refreshPromiseRef = useRef(null); + const breadcrumbFetchRef = useRef(new Set()); + const tagRemovalCursorActiveRef = useRef(false); + const tenantIdRef = useRef(currentTenantId); + 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('skeuo-cursor-remove'); + } else { + body.classList.remove('skeuo-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 [tags, setTags] = useState([]); + const [correspondents, setCorrespondents] = useState([]); + const [documentTypes, setDocumentTypes] = useState([]); + const [webdavTokens, setWebdavTokens] = useState([]); + const [webdavTokensLoading, setWebdavTokensLoading] = useState(false); + const [creatingWebdavToken, setCreatingWebdavToken] = useState(false); + const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null); + const [webdavTokenSecret, setWebdavTokenSecret] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [activeTagFilters, setActiveTagFilters] = useState([]); + const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); + const [activeDocumentTypeFilters, setActiveDocumentTypeFilters] = 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) => + previous.includes(tagId) + ? previous.filter((id) => id !== tagId) + : previous.concat([tagId]), + ); + }, []); + + const toggleCorrespondentFilter = useCallback((correspondentId) => { + setActiveCorrespondentFilters((previous) => { + if (!correspondentId) { + return []; + } + return previous.includes(correspondentId) ? [] : [correspondentId]; + }); + }, []); + + const toggleDocumentTypeFilter = useCallback((documentTypeId) => { + setActiveDocumentTypeFilters((previous) => { + if (!documentTypeId) { + return []; + } + return previous.includes(documentTypeId) ? [] : [documentTypeId]; + }); + }, []); + + 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]); + + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); + } + if (typeof window !== 'undefined') { + window.localStorage.setItem('papercrate_neutral_hue', String(neutralHue)); + } + }, [neutralHue]); + + const handleNeutralHueChange = useCallback((value) => { + if (value === '') { + setNeutralHue(260); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralHue(parsed); + }, []); + + const clearFilters = useCallback(() => { + setSearchQuery(''); + setActiveTagFilters([]); + setActiveCorrespondentFilters([]); + setActiveDocumentTypeFilters([]); + setSearchLoading(false); + }, []); + + 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 getDocumentAsset = useCallback((doc, type) => { + if (!doc || !type) return null; + return getAssetFromVersion(doc.current_version || null, type); + }, []); + + const bootstrapInitializedRef = useRef(false); + const selectionInitializedRef = useRef(false); + const dragCounterRef = useRef(0); + const detailFolderFetchRef = useRef(new Set()); + const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null); + const selectionOrderRef = useRef(initialRowSelection); + + const selectedDocumentIds = useMemo( + () => + selectedRowKeys + .filter(isDocumentRowKey) + .map((key) => getRowId(key)) + .filter(Boolean), + [selectedRowKeys], + ); + + const selectedFolderIds = useMemo( + () => + selectedRowKeys + .filter(isFolderRowKey) + .map((key) => getRowId(key)) + .filter(Boolean), + [selectedRowKeys], + ); + + const [detailPanelOpen, setDetailPanelOpen] = useState(() => selectedDocumentIds.length > 0); + const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); + const [detailPanelDocs, setDetailPanelDocs] = useState([]); + + useEffect(() => { + if (selectedDocumentIds.length > 0 && !detailPanelOpen) { + setDetailPanelOpen(true); + } + }, [selectedDocumentIds, detailPanelOpen]); + + const resetWorkspaceState = useCallback(() => { + const rootNode = createRootNode(); + setFolderNodes(new Map([[rootNode.id, rootNode]])); + setFolderContents(new Map()); + setSelectedFolder('root'); + setCurrentFolder(null); + setCurrentSubfolders([]); + setDocuments([]); + setSelectedRowKeys([]); + setSelectionOrder([]); + selectionOrderRef.current = []; + setFocusedDocumentId(null); + selectionAnchorRef.current = null; + setDraggedDocumentIds([]); + setDraggedFolderId(null); + setSearchResults(null); + setTags([]); + setCorrespondents([]); + setDocumentTypes([]); + setWebdavTokens([]); + setWebdavTokensLoading(false); + setCreatingWebdavToken(false); + setDeletingWebdavTokenId(null); + setWebdavTokenSecret(null); + setSearchQuery(''); + setActiveTagFilters([]); + setActiveCorrespondentFilters([]); + setActiveDocumentTypeFilters([]); + setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); + setActivePreviewId(null); + setDetailPanelOpen(false); + setDetailPanelDocIds([]); + setDetailPanelDocs([]); + 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]); + + 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]); + + const documentTypeLookupByName = useMemo(() => { + const map = new Map(); + documentTypes.forEach((entry) => { + if (entry?.name) { + map.set(entry.name.toLowerCase(), entry); + } + }); + return map; + }, [documentTypes]); + + const documentTypeLookupById = useMemo(() => { + const map = new Map(); + documentTypes.forEach((entry) => { + if (entry?.id) { + map.set(entry.id, entry); + } + }); + return map; + }, [documentTypes]); + + const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => { + const nextSet = new Set(nextSelection); + const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); + const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); + + const base = previousOrder.filter((id) => !interacted.includes(id)); + const result = [...base]; + + interacted.forEach((id) => { + if (nextSet.has(id) && !result.includes(id)) { + result.push(id); + } + }); + + nextSelection.forEach((id) => { + if (!result.includes(id)) { + result.push(id); + } + }); + + if ( + result.length !== selectionOrderRef.current.length || + result.some((id, index) => selectionOrderRef.current[index] !== id) + ) { + selectionOrderRef.current = result; + setSelectionOrder(result); + } else { + selectionOrderRef.current = result; + } + }, []); + + useEffect(() => { + if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { + resetWorkspaceState(); + } + }, [appStatus, resetWorkspaceState]); + + 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]); + + 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 || + activeDocumentTypeFilters.length > 0, + [searchQuery, activeTagFilters, activeCorrespondentFilters, activeDocumentTypeFilters], + ); + + 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 = []; + + setSelectedRowKeys((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, 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 removeDocumentFromCaches = useCallback( + (documentId) => { + if (!documentId) { + return; + } + + const removeFromList = (list) => { + const next = list.filter((doc) => doc.id !== documentId); + return next.length === list.length ? list : next; + }; + + setDocuments((prev) => removeFromList(prev)); + setSearchResults((prev) => (Array.isArray(prev) ? removeFromList(prev) : 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; + } + const filteredDocs = docs.filter((doc) => doc.id !== documentId); + if (filteredDocs.length !== docs.length) { + changed = true; + next.set(key, { ...contents, documents: filteredDocs }); + } else { + next.set(key, contents); + } + }); + return changed ? next : prev; + }); + }, + [setDocuments, setSearchResults, setFolderContents], + ); + + const applySelection = useCallback( + (rowKeys, { anchor, interactedKeys = [] } = {}) => { + const unique = []; + + (rowKeys || []).forEach((key) => { + if (!key) return; + const canonicalKey = visibleRowKeySet.has(key) + ? key + : isDocumentRowKey(key) + ? resolveDocumentRowKey(getRowId(key)) + : isFolderRowKey(key) + ? resolveFolderRowKey(getRowId(key)) + : null; + + if (!canonicalKey || !visibleRowKeySet.has(canonicalKey)) { + return; + } + + if (!unique.includes(canonicalKey)) { + unique.push(canonicalKey); + } + }); + + let resolvedAnchor = anchor; + if (resolvedAnchor && !unique.includes(resolvedAnchor)) { + resolvedAnchor = null; + } + + setSelectedRowKeys(unique); + updateSelectionOrder(unique, interactedKeys); + + const nextFocusedDocumentId = (() => { + if (focusedDocumentId) { + const focusKey = resolveDocumentRowKey(focusedDocumentId); + if (focusKey && unique.includes(focusKey)) { + return focusedDocumentId; + } + } + + if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { + return getRowId(resolvedAnchor) || null; + } + + const lastDocKey = [...unique].reverse().find(isDocumentRowKey); + return lastDocKey ? getRowId(lastDocKey) || null : null; + })(); + + setFocusedDocumentId(nextFocusedDocumentId); + + if (resolvedAnchor) { + selectionAnchorRef.current = resolvedAnchor; + } else if (!unique.length) { + selectionAnchorRef.current = null; + } else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) { + selectionAnchorRef.current = unique[unique.length - 1]; + } + + return { selection: unique, focusKey: selectionAnchorRef.current }; + }, + [visibleRowKeySet, focusedDocumentId, updateSelectionOrder], + ); + + const promoteSelectionOrder = useCallback( + (docId) => { + if (!docId) return; + const rowKey = resolveDocumentRowKey(docId); + if (!rowKey) return; + if (!selectedRowKeys.includes(rowKey)) return; + updateSelectionOrder(selectedRowKeys, [rowKey]); + selectionAnchorRef.current = rowKey; + setFocusedDocumentId(docId); + setActivePreviewId(docId); + }, + [selectedRowKeys, updateSelectionOrder], + ); + + + 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], + ); + + const handleRowSelection = useCallback( + (rowKey, event) => { + if (!rowKey || !visibleRowKeySet.has(rowKey)) { + return; + } + + setFocusedRowKey(rowKey); + + const shiftKey = Boolean(event?.shiftKey); + const metaKey = Boolean(event?.metaKey); + const ctrlKey = Boolean(event?.ctrlKey); + const additive = metaKey || ctrlKey; + + if (shiftKey) { + event?.preventDefault?.(); + } + + let anchorKey = selectionAnchorRef.current; + if (!anchorKey && shiftKey && selectedRowKeys.length) { + anchorKey = selectedRowKeys[selectedRowKeys.length - 1]; + } + if (!anchorKey) { + anchorKey = rowKey; + } + + let nextKeys = []; + let interactedKeys = []; + + if (shiftKey && anchorKey) { + const anchorIndex = navigableRowKeys.indexOf(anchorKey); + const targetIndex = navigableRowKeys.indexOf(rowKey); + if (anchorIndex !== -1 && targetIndex !== -1) { + const [start, end] = + anchorIndex <= targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex]; + const range = navigableRowKeys.slice(start, end + 1); + nextKeys = range; + + const previousSet = new Set(selectedRowKeys); + interactedKeys = range.filter( + (key) => key === rowKey || !previousSet.has(key), + ); + if (!interactedKeys.includes(rowKey)) { + interactedKeys.push(rowKey); + } + } else { + nextKeys = [rowKey]; + interactedKeys = [rowKey]; + } + } else if (additive) { + if (selectedRowKeys.includes(rowKey)) { + nextKeys = selectedRowKeys.filter((key) => key !== rowKey); + interactedKeys = []; + } else { + nextKeys = [...selectedRowKeys, rowKey]; + interactedKeys = [rowKey]; + } + anchorKey = rowKey; + } else { + nextKeys = [rowKey]; + interactedKeys = [rowKey]; + anchorKey = rowKey; + } + + applySelection(nextKeys, { anchor: anchorKey, interactedKeys }); + }, + [applySelection, navigableRowKeys, selectedRowKeys, setFocusedRowKey, visibleRowKeySet], + ); + + const handleDocumentRowClick = useCallback( + (documentId, event) => { + const rowKey = resolveDocumentRowKey(documentId); + if (!rowKey) return; + handleRowSelection(rowKey, event); + }, + [handleRowSelection], + ); + + const clearDocumentSelection = useCallback(() => { + setFocusedRowKey(null); + applySelection([], { anchor: null, interactedKeys: [] }); + }, [applySelection]); + + const handleFolderRowClick = useCallback( + (folderId, event) => { + const rowKey = resolveFolderRowKey(folderId); + if (!rowKey) return; + handleRowSelection(rowKey, event); + }, + [handleRowSelection], + ); + + 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]); + + useEffect(() => { + if (!focusedRowKey) { + return; + } + if (navigableRowKeys.includes(focusedRowKey)) { + return; + } + const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null; + if (docKey && navigableRowKeys.includes(docKey)) { + setFocusedRowKey(docKey); + return; + } + if (navigableRowKeys.length) { + setFocusedRowKey(navigableRowKeys[0]); + } else { + setFocusedRowKey(null); + } + }, [focusedRowKey, navigableRowKeys, focusedDocumentId]); + + + const ensureFolderData = useCallback( + async ( + folderId, + { force = false, includeDocuments = true, prefetchDepth = 0 } = {}, + ) => { + const requestTenantId = tenantIdRef.current; + const cached = folderContents.get(folderId); + if (!force && cached) { + const includesDocuments = Boolean(cached.__includesDocuments); + if (!includeDocuments || includesDocuments) { + 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 = includeDocuments + ? undefined + : { include_documents: false }; + const { data } = await api.get(`/folders/${path}/contents`, { + params, + }); + const hydrated = assetManager.hydrateFolderContents(data); + const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; + const childIds = childFolders.map((child) => child.id); + + if (tenantIdRef.current !== requestTenantId) { + return { ...hydrated, __includesDocuments: includeDocuments }; + } + + 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, + }), + ), + ); + } + + const enriched = { + ...hydrated, + __includesDocuments: includeDocuments, + }; + + 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, + }); + } else { + next.set(folderId, enriched); + } + return next; + }); + } + + return enriched; + }, + [assetManager, folderContents], + ); + + 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 refreshDocumentTypes = useCallback(async () => { + const requestTenantId = tenantIdRef.current; + try { + const { data } = await api.get('/document-types'); + if (tenantIdRef.current !== requestTenantId) { + return; + } + setDocumentTypes(data || []); + } catch (error) { + if (tenantIdRef.current !== requestTenantId) { + return; + } + notifyApiError(error, 'Unable to load document types.'); + } + }, [notifyApiError]); + + const refreshWebdavTokens = useCallback(async () => { + if (!token) { + return; + } + setWebdavTokensLoading(true); + try { + const { data } = await api.get('/profile/webdav-tokens'); + setWebdavTokens(Array.isArray(data) ? data : []); + } catch (error) { + notifyApiError(error, 'Failed to load WebDAV tokens.'); + } finally { + setWebdavTokensLoading(false); + } + }, [notifyApiError, token]); + + const createWebdavToken = useCallback( + async ({ label, expires_at } = {}) => { + if (creatingWebdavToken) { + return false; + } + setCreatingWebdavToken(true); + try { + const payload = {}; + if (label) { + payload.label = label; + } + if (expires_at) { + payload.expires_at = expires_at; + } + const { data } = await api.post('/profile/webdav-tokens', payload); + if (data?.token_info) { + setWebdavTokens((previous) => { + const filtered = previous.filter((entry) => entry.id !== data.token_info.id); + return [data.token_info, ...filtered]; + }); + } else { + await refreshWebdavTokens(); + } + if (data?.token) { + setWebdavTokenSecret(data.token); + } + setStatusMessage('WebDAV token created.', 'success'); + return data; + } catch (error) { + notifyApiError(error, 'Failed to create WebDAV token.'); + return false; + } finally { + setCreatingWebdavToken(false); + } + }, + [creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage], + ); + + const deleteWebdavToken = useCallback( + async (tokenId) => { + if (!tokenId) { + return false; + } + setDeletingWebdavTokenId(tokenId); + try { + await api.delete(`/profile/webdav-tokens/${tokenId}`); + await refreshWebdavTokens(); + setStatusMessage('WebDAV token revoked.', 'success'); + return true; + } catch (error) { + notifyApiError(error, 'Failed to revoke WebDAV token.'); + return false; + } finally { + setDeletingWebdavTokenId(null); + } + }, + [refreshWebdavTokens, notifyApiError, setStatusMessage], + ); + + const dismissCreatedWebdavToken = useCallback(() => { + setWebdavTokenSecret(null); + }, []); + + const { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + } = usePasskeys({ + api, + notifyApiError, + setStatusMessage, + token, + }); + + useEffect(() => { + if (!isSettingsModalOpen) { + return; + } + refreshWebdavTokens(); + refreshPasskeys(); + }, [isSettingsModalOpen, refreshPasskeys, refreshWebdavTokens]); + + useEffect(() => { + if (!isSettingsModalOpen) { + setWebdavTokenSecret(null); + } + }, [isSettingsModalOpen]); + + 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]); + + 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 }) => { + 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 = 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 handleDocumentTypeUpdate = useCallback( + async (documentTypeId, changes) => { + if (!documentTypeId) { + throw new Error('Missing document type identifier.'); + } + + const payload = {}; + if (typeof changes.name === 'string') { + const trimmed = changes.name.trim(); + if (!trimmed) { + throw new Error('Document type name cannot be empty.'); + } + payload.name = trimmed; + } + + if (Object.keys(payload).length === 0) { + return false; + } + + try { + await api.patch(`/document-types/${documentTypeId}`, payload); + await refreshDocumentTypes(); + setStatusMessage('Document type updated.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update document type.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [refreshDocumentTypes, notifyApiError, setStatusMessage], + ); + + const handleDocumentTypeCreate = useCallback( + async ({ name }) => { + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (!trimmed) { + throw new Error('Document type name is required.'); + } + try { + const { data } = await api.post('/document-types', { name: trimmed }); + await refreshDocumentTypes(); + setStatusMessage('Document type created.', 'success'); + return data; + } catch (error) { + const message = error.response?.data?.error || 'Failed to create document type.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [refreshDocumentTypes, notifyApiError, setStatusMessage], + ); + + const handleDocumentTypeDelete = useCallback( + async (documentTypeId) => { + if (!documentTypeId) { + throw new Error('Missing document type identifier.'); + } + + const deletedEntry = documentTypeLookupById.get(documentTypeId); + + try { + await api.delete(`/document-types/${documentTypeId}`); + + mapDocumentCaches((doc) => { + if (!doc) { + return doc; + } + + const existingType = doc.document_type; + const existingTypeId = doc.document_type_id ?? existingType?.id ?? null; + const existingTypeName = typeof existingType?.name === 'string' ? existingType.name : undefined; + + const shouldClear = + existingTypeId === documentTypeId || + (!!deletedEntry?.name && + existingTypeName && + existingTypeName.toLowerCase() === deletedEntry.name.toLowerCase()); + + if (!shouldClear) { + return doc; + } + + return { ...doc, document_type: null, document_type_id: null }; + }); + + await refreshDocumentTypes(); + setStatusMessage('Document type deleted.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete document type.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [ + refreshDocumentTypes, + notifyApiError, + setStatusMessage, + mapDocumentCaches, + documentTypeLookupById, + ], + ); + + const handleDocumentTypeAssign = useCallback( + async ({ documentId, documentTypeId, documentType }, { notify = true } = {}) => { + if (!documentId) { + throw new Error('Missing document identifier.'); + } + + try { + const payload = { document_type_id: documentTypeId ?? null }; + const { data } = await api.patch(`/documents/${documentId}`, payload); + const updatedDocument = extractDocumentFromResponse(data); + + updateDocumentCaches(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + + const next = { ...doc }; + if (documentTypeId) { + const entry = + documentTypeLookupById.get(documentTypeId) || + (documentType?.name ? documentType : null); + next.document_type = entry + ? { id: entry.id ?? documentTypeId, name: entry.name } + : { id: documentTypeId, name: '' }; + } else { + next.document_type = null; + } + next.document_type_id = documentTypeId ?? null; + return next; + }); + + if (notify) { + setStatusMessage( + documentTypeId ? 'Document type updated.' : 'Document type cleared.', + 'success', + ); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update document type.'; + notifyApiError(error, message); + return false; + } + }, + [ + extractDocumentFromResponse, + updateDocumentCaches, + documentTypeLookupById, + notifyApiError, + setStatusMessage, + ], + ); + + const handleDocumentTypeClear = useCallback( + async ({ documentId }, options = {}) => + handleDocumentTypeAssign({ documentId, documentTypeId: null }, options), + [handleDocumentTypeAssign], + ); + + const handleDocumentTypeSet = useCallback( + async ({ document, name, input }) => { + if (!document?.id) { + throw new Error('Missing document for document type assignment.'); + } + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (!trimmed) { + setStatusMessage('Document type name is required.', 'error'); + return; + } + + let target = documentTypeLookupByName.get(trimmed.toLowerCase()) || null; + if (!target) { + try { + target = await handleDocumentTypeCreate({ name: trimmed }); + } catch (error) { + return; + } + } + + if (!target?.id) { + setStatusMessage('Unable to resolve document type.', 'error'); + return; + } + + const success = await handleDocumentTypeAssign( + { documentId: document.id, documentTypeId: target.id, documentType: target }, + { notify: true }, + ); + + if (success && input) { + input.value = ''; + } + }, + [ + handleDocumentTypeCreate, + handleDocumentTypeAssign, + documentTypeLookupByName, + 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 handleBulkDocumentTypeSet = useCallback( + async ({ name, input, documentIds }) => { + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (!trimmed) { + setStatusMessage('Document type name is required.', 'error'); + return; + } + + const targets = resolveTargetDocumentIds(documentIds); + if (!targets.length) { + setStatusMessage('Select documents before assigning a document type.', 'error'); + return; + } + + let target = documentTypeLookupByName.get(trimmed.toLowerCase()) || null; + if (!target) { + try { + target = await handleDocumentTypeCreate({ name: trimmed }); + } catch (error) { + return; + } + } + + if (!target?.id) { + setStatusMessage('Unable to resolve document type.', 'error'); + return; + } + + try { + await Promise.all( + targets.map((documentId) => + api.patch(`/documents/${documentId}`, { document_type_id: target.id }), + ), + ); + await refreshCurrentFolder(); + const suffix = targets.length === 1 ? '' : 's'; + setStatusMessage( + `Document type assigned to ${targets.length} document${suffix}.`, + 'success', + ); + if (input) { + input.value = ''; + } + } catch (error) { + const message = error.response?.data?.error || 'Failed to assign document type.'; + notifyApiError(error, message); + } + }, + [ + documentTypeLookupByName, + handleDocumentTypeCreate, + refreshCurrentFolder, + resolveTargetDocumentIds, + setStatusMessage, + notifyApiError, + ], + ); + + const handleBulkDocumentTypeClear = useCallback( + async ({ documentIds }) => { + const targets = resolveTargetDocumentIds(documentIds); + if (!targets.length) { + setStatusMessage('Select documents before clearing the document type.', 'error'); + return; + } + + try { + await Promise.all( + targets.map((documentId) => api.patch(`/documents/${documentId}`, { document_type_id: null })), + ); + await refreshCurrentFolder(); + const suffix = targets.length === 1 ? '' : 's'; + setStatusMessage( + `Document type cleared from ${targets.length} document${suffix}.`, + 'success', + ); + } catch (error) { + const message = error.response?.data?.error || 'Failed to clear document type.'; + notifyApiError(error, message); + } + }, + [resolveTargetDocumentIds, refreshCurrentFolder, setStatusMessage, notifyApiError], + ); + + 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(), refreshDocumentTypes()]); + 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, refreshDocumentTypes, 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); + const folderId = data.folder.id; + cache.set(cacheKey, folderId); + return folderId; + }, + [], + ); + + 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 > maxVisible ? `+${totalCount - maxVisible}` : `${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 ? selectedRowKeys.includes(folderKey) : false; + + let effectiveFolderSelection = selectedFolderIds; + let effectiveDocumentSelection = selectedDocumentIds; + + if (!isAlreadySelected && folderKey) { + effectiveFolderSelection = [folderId]; + effectiveDocumentSelection = []; + handleRowSelection(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'); + }, + [ + selectedRowKeys, + selectedFolderIds, + selectedDocumentIds, + setDraggedFolderId, + setDraggedDocumentIds, + handleRowSelection, + 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 || 'document', + 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 target = targetFolderId === 'root' ? null : targetFolderId; + 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'; + const targetLabel = + target === null + ? DEFAULT_FOLDER_NAME + : folderLabelMap.get(targetFolderId) || 'target folder'; + setStatusMessage( + `Moved ${count} document${suffix} to ${targetLabel}.`, + 'success', + ); + + await refreshCurrentFolder(); + 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); + } + }, + [ + ensureFolderData, + refreshCurrentFolder, + selectedFolder, + folderLabelMap, + notifyApiError, + setStatusMessage, + ], + ); + + 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]; + }); + } + + const targetFolder = doc?.folder_id || selectedFolder || 'root'; + if (targetFolder && targetFolder !== selectedFolder) { + await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive }); + doc = findInCache() || doc; + } + + await ensurePreviewUrl(documentId, { force: false }); + setActivePreviewId(documentId); + return doc; + }, + [ + searchResults, + documents, + assetManager, + setDocuments, + selectedFolder, + loadFolder, + isFilterActive, + ensurePreviewUrl, + setActivePreviewId, + ], + ); + + const openDocumentPreview = useCallback( + (documentId, { replace = false } = {}) => { + if (!documentId) return; + navigate(`/documents/${documentId}`, { replace }); + }, + [navigate], + ); + + const closeDocumentPreview = useCallback( + (folderId = null) => { + const targetId = folderId || selectedFolder || 'root'; + const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; + navigate(path, { replace: false }); + }, + [navigate, selectedFolder], + ); + + const handleDocumentListFocus = useCallback(() => { + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + return; + } + + let resolvedKey = null; + for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) { + const candidate = selectedRowKeys[index]; + if (navigableRowKeys.includes(candidate)) { + resolvedKey = candidate; + break; + } + } + + if (!resolvedKey && navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + + if (!resolvedKey) { + return; + } + + setFocusedRowKey(resolvedKey); + + if (!selectedRowKeys.includes(resolvedKey)) { + applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] }); + } + }, [ + focusedRowKey, + navigableRowKeys, + selectedRowKeys, + navigableRows, + applySelection, + ]); + + const handleDocumentListKeyDown = useCallback( + (event) => { + const { key, shiftKey } = event; + const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; + if (!triggers.includes(key)) { + return; + } + + if (!navigableRows.length) { + return; + } + + event.preventDefault(); + + let activeKey = + focusedRowKey && navigableRowKeys.includes(focusedRowKey) + ? focusedRowKey + : null; + + if (!activeKey) { + for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) { + const candidate = selectedRowKeys[index]; + if (navigableRowKeys.includes(candidate)) { + activeKey = candidate; + break; + } + } + } + + if (!activeKey) { + activeKey = navigableRowKeys[0]; + setFocusedRowKey(activeKey); + } + + let currentIndex = navigableRowKeys.indexOf(activeKey); + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + const row = currentIndex === -1 ? navigableRows[0] : navigableRows[currentIndex]; + if (!row) { + return; + } + handleRowSelection(row.key, event); + if (row.type === 'folder') { + selectFolder(row.id); + } else if (row.type === 'document') { + openDocumentPreview(row.id); + } + return; + } + + let nextIndex = currentIndex; + + if (key === 'ArrowDown') { + nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); + } else if (key === 'ArrowUp') { + nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); + } else if (key === 'Home') { + nextIndex = 0; + } else if (key === 'End') { + nextIndex = navigableRows.length - 1; + } + + if (nextIndex === -1 || nextIndex >= navigableRows.length) { + return; + } + + if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') { + return; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + setFocusedRowKey(targetRow.key); + + handleRowSelection(targetRow.key, { + shiftKey, + preventDefault: () => {}, + }); + }, + [ + navigableRows, + navigableRowKeys, + focusedRowKey, + selectedRowKeys, + handleRowSelection, + selectFolder, + openDocumentPreview, + ], + ); + + 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 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, input) => { + const normalizedLabel = tagManager.normalizeLabel(label); + let 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'); + 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) { + return { id: tagId, label: 'Tag', color: null }; + } + return { + id: source.id ?? tagId, + label: source.label || source.name || 'Tag', + 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; + } + return { ...doc, tags: [...currentTags, resolveTagForCache()] }; + }); + setStatusMessage('Tag assigned.', 'success'); + await refreshCurrentFolder(); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to assign tag.'; + notifyApiError(error, message); + return false; + } + }, + [ + refreshCurrentFolder, + notifyApiError, + setStatusMessage, + updateDocumentCaches, + tagLookupById, + ], + ); + + 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 handleFolderDelete = useCallback( + async (folderId) => { + if (!token) { + setStatusMessage('Log in to manage folders.', 'error'); + return; + } + if (folderId === 'root') { + setStatusMessage('The root folder cannot be removed.', 'error'); + return; + } + 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) { + setStatusMessage('Folder must be empty before it can be deleted.', 'error'); + return; + } + 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 }); + } + setStatusMessage('Folder deleted.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete folder.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [ + token, + ensureFolderData, + selectedFolder, + folderNodes, + applySelectedFolder, + notifyApiError, + setStatusMessage, + ], + ); + + 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 openTagsModal = useCallback(() => { + setCorrespondentsModalOpen(false); + setDocumentTypesModalOpen(false); + setTagsModalOpen(true); + }, []); + + const closeTagsModal = useCallback(() => { + setTagsModalOpen(false); + }, []); + + const openCorrespondentsModal = useCallback(() => { + setTagsModalOpen(false); + setCorrespondentsModalOpen(true); + setDocumentTypesModalOpen(false); + }, []); + + const closeCorrespondentsModal = useCallback(() => { + setCorrespondentsModalOpen(false); + }, []); + + const openDocumentTypesModal = useCallback(() => { + setTagsModalOpen(false); + setCorrespondentsModalOpen(false); + setDocumentTypesModalOpen(true); + }, []); + + const closeDocumentTypesModal = useCallback(() => { + setDocumentTypesModalOpen(false); + }, []); + + const openSettingsModal = useCallback(() => { + setSettingsModalOpen(true); + }, []); + + const closeSettingsModal = useCallback(() => { + setSettingsModalOpen(false); + }, []); + + useEffect(() => { + setTagsModalOpen(false); + setCorrespondentsModalOpen(false); + setDocumentTypesModalOpen(false); + setSettingsModalOpen(false); + }, [location.pathname]); + + useEffect(() => { + if (!isTagsModalOpen && !isCorrespondentsModalOpen && !isDocumentTypesModalOpen && !isSettingsModalOpen) { + return; + } + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + if (isTagsModalOpen) { + closeTagsModal(); + } else if (isCorrespondentsModalOpen) { + closeCorrespondentsModal(); + } else if (isDocumentTypesModalOpen) { + closeDocumentTypesModal(); + } else if (isSettingsModalOpen) { + closeSettingsModal(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [ + isTagsModalOpen, + isCorrespondentsModalOpen, + isDocumentTypesModalOpen, + isSettingsModalOpen, + closeTagsModal, + closeCorrespondentsModal, + closeDocumentTypesModal, + closeSettingsModal, + ]); + + 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) { + params.tags = activeTagFilters.join(','); + } + if (activeCorrespondentFilters.length) { + params.correspondents = activeCorrespondentFilters.join(','); + } + if (activeDocumentTypeFilters.length) { + params.document_types = activeDocumentTypeFilters.join(','); + } + const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; + if (folderIdentifier) { + params.folder_id = folderIdentifier; + } + const { data } = await api.get('/documents', { params }); + if (cancelled) return; + + const results = assetManager.hydrateDocuments(data || []); + setSearchResults(results); + + if (!results.length) { + setSearchLoading(false); + setSelectedRowKeys([]); + setFocusedDocumentId(null); + selectionOrderRef.current = []; + setSelectionOrder([]); + selectionAnchorRef.current = null; + return; + } + + const resultKeys = results + .map((doc) => resolveDocumentRowKey(doc.id)) + .filter(Boolean); + + let targetKey = null; + let nextSelectionKeys = []; + + setSelectedRowKeys((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 = resultKeys[0] || null; + nextSelectionKeys = targetKey ? [targetKey] : []; + return nextSelectionKeys; + }); + + selectionOrderRef.current = nextSelectionKeys; + setSelectionOrder(nextSelectionKeys); + + const targetDocId = targetKey ? getRowId(targetKey) : null; + + setFocusedDocumentId((previous) => { + if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { + return previous; + } + return targetDocId; + }); + + 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, + activeDocumentTypeFilters, + selectedFolder, + notifyApiError, + assetManager, + ]); + + 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) => { + const types = event?.dataTransfer?.types; + if (!types) { + return false; + } + if (typeof types.includes === 'function') { + return TAG_MIME_TYPES.some((type) => types.includes(type)); + } + return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type)); + }; + + 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 handleDocumentDelete = useCallback( + async (documentId) => { + if (!documentId) return; + if (!token) { + setStatusMessage('Log in to manage documents.', 'error'); + return; + } + + const doc = documentLookup.get(documentId) || null; + const label = doc?.title; + + const confirmed = window.confirm(`Move "${label}" to trash? You can restore it from trash later.`); + if (!confirmed) { + return; + } + + setLoading(true); + try { + await api.delete(`/documents/${documentId}`); + + removeDocumentFromCaches(documentId); + + setPreviewEntries((prev) => { + if (!prev.has(documentId)) { + return prev; + } + const next = new Map(prev); + next.delete(documentId); + return next; + }); + previewInflightRef.current.delete(documentId); + + if (selectedDocumentIds.includes(documentId)) { + const remainingRowKeys = selectedDocumentIds + .filter((id) => id !== documentId) + .map((id) => resolveDocumentRowKey(id)) + .filter(Boolean); + const removedKey = resolveDocumentRowKey(documentId); + applySelection(remainingRowKeys, { + anchor: null, + interactedKeys: removedKey ? [removedKey] : [], + }); + } + + if (previewDocumentId === documentId) { + closeDocumentPreview(); + } + + setStatusMessage('Document deleted.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete document.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [ + token, + documentLookup, + setStatusMessage, + removeDocumentFromCaches, + setPreviewEntries, + previewInflightRef, + previewDocumentId, + closeDocumentPreview, + selectedDocumentIds, + applySelection, + notifyApiError, + ], + ); + + 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]); + + useEffect(() => { + if (!orderedSelectedDocuments.length) { + return; + } + + const nextIds = orderedSelectedDocuments + .map((doc) => (doc?.id ? doc.id : null)) + .filter(Boolean); + + setDetailPanelDocIds((prev) => { + if (nextIds.length === prev.length && nextIds.every((id, index) => id === prev[index])) { + return prev; + } + return nextIds; + }); + }, [orderedSelectedDocuments]); + + useEffect(() => { + if (!detailPanelDocIds.length) { + setDetailPanelDocs([]); + return; + } + + setDetailPanelDocs((prevDocs) => { + const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc])); + const next = detailPanelDocIds + .map((id) => documentLookup.get(id) || prevMap.get(id) || null) + .filter(Boolean); + + if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) { + return prevDocs; + } + return next; + }); + }, [detailPanelDocIds, documentLookup]); + + const detailPanelSelectedDocuments = detailPanelDocs; + + const lastScrolledDetailDocRef = useRef(null); + + useEffect(() => { + if (!detailPanelOpen) { + lastScrolledDetailDocRef.current = null; + return; + } + + if (!orderedSelectedDocuments.length) { + lastScrolledDetailDocRef.current = null; + return; + } + + let lastSelectedId = null; + for (let index = selectionOrder.length - 1; index >= 0; index -= 1) { + const key = selectionOrder[index]; + if (isDocumentRowKey(key)) { + lastSelectedId = getRowId(key); + if (lastSelectedId) { + break; + } + } + } + + if (!lastSelectedId && orderedSelectedDocuments.length) { + const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; + lastSelectedId = fallbackDoc?.id || null; + } + + if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) { + return; + } + + if (typeof document === 'undefined') { + return; + } + + const targetElement = + document.getElementById(`document-row-${lastSelectedId}`) || + document.getElementById(`document-card-${lastSelectedId}`); + + if (!targetElement) { + return; + } + + lastScrolledDetailDocRef.current = lastSelectedId; + requestAnimationFrame(() => { + targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + }, [orderedSelectedDocuments, selectionOrder, documentsViewMode, detailPanelOpen]); + + 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 showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]); + const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [setWorkspaceMode]); + const handleDocumentsViewModeChange = useCallback((mode) => { + setDocumentsViewMode((previous) => { + const next = mode === 'grid' ? 'grid' : 'list'; + if (next !== previous && typeof window !== 'undefined') { + window.localStorage.setItem('papercrate_view_mode', next); + } + return next; + }); + }, []); + + 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 }); + } + + setWorkspaceMode('table'); + navigate('/documents', { replace: true }); + + await Promise.all([refreshTags(), refreshCorrespondents(), refreshDocumentTypes()]); + 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, + setWorkspaceMode, + navigate, + refreshTags, + refreshCorrespondents, + refreshDocumentTypes, + loadFolder, + ], + ); + + const documentsTableProps = useMemo( + () => ({ + currentFolderName, + breadcrumbs, + onRefresh: refreshCurrentFolder, + onShowSkeuoWorkspace: showSkeuoWorkspace, + subfolders: currentSubfolders, + documents, + searchResults, + isFilterActive, + onFolderSelect: selectFolder, + onFolderDrop: folderClickHandlers.onDrop, + onFolderDragOver: folderClickHandlers.onDragOver, + onFolderDragLeave: folderClickHandlers.onDragLeave, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onFolderDelete: handleFolderDelete, + onFolderRowClick: handleFolderRowClick, + onFolderRename: handleFolderRename, + onDocumentRowClick: handleDocumentRowClick, + onDocumentOpen: openDocumentPreview, + onDocumentDelete: handleDocumentDelete, + onDocumentRename: handleDocumentTitleUpdate, + selectedDocumentIds, + selectedFolderIds, + focusedDocumentId, + focusedRowKey, + draggingDocumentIds: draggedDocumentIds, + onDocumentDragStart: handleDocumentDragStart, + onDocumentDragEnd: handleDocumentDragEnd, + isSearchLoading: searchLoading, + tagLookupById, + activeCorrespondentIds: activeCorrespondentFilters, + onDocumentListFocus: handleDocumentListFocus, + onDocumentListKeyDown: handleDocumentListKeyDown, + onFocusedRowChange: setFocusedRowKey, + ensureAssetUrl, + getDocumentAsset, + getDownloadHref: (doc) => + doc?.current_version?.download_path + ? resolveApiPath(doc.current_version.download_path) + : null, + onTagClick: toggleTagFilter, + onCorrespondentClick: toggleCorrespondentFilter, + onDocumentTagDrop: handleDocumentTagDrop, + viewMode: documentsViewMode, + onViewModeChange: handleDocumentsViewModeChange, + onClearSelection: clearDocumentSelection, + }), + [ + activeCorrespondentFilters, + breadcrumbs, + clearDocumentSelection, + currentFolderName, + currentSubfolders, + documents, + documentsViewMode, + draggedDocumentIds, + draggedFolderId, + focusedDocumentId, + focusedRowKey, + folderClickHandlers, + handleDocumentDelete, + handleDocumentDragEnd, + handleDocumentDragStart, + handleDocumentListFocus, + handleDocumentListKeyDown, + handleDocumentRowClick, + handleDocumentTagDrop, + handleDocumentTitleUpdate, + handleDocumentsViewModeChange, + handleFolderDelete, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderRename, + handleFolderRowClick, + isFilterActive, + openDocumentPreview, + refreshCurrentFolder, + searchLoading, + searchResults, + selectFolder, + selectedDocumentIds, + selectedFolderIds, + setFocusedRowKey, + showSkeuoWorkspace, + tagLookupById, + toggleCorrespondentFilter, + toggleTagFilter, + ensureAssetUrl, + getDocumentAsset, + ], + ); + + 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, + onNeutralHueChange: handleNeutralHueChange, + neutralHue, + tags, + activeTagIds: activeTagFilters, + onToggleTagFilter: toggleTagFilter, + onCreateTag: (label) => handleTagCreate({ label }), + correspondents, + activeCorrespondentIds: activeCorrespondentFilters, + onToggleCorrespondentFilter: toggleCorrespondentFilter, + onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }), + documentTypes, + activeDocumentTypeIds: activeDocumentTypeFilters, + onToggleDocumentTypeFilter: toggleDocumentTypeFilter, + onCreateDocumentType: (name) => handleDocumentTypeCreate({ name }), + appStatus, + loading, + previewActive, + searchQuery, + onSearchChange: handleSearchChange, + onSearchSubmit: handleSearchSubmit, + onSearchClear: clearFilters, + isFilterActive, + onLogout: handleLogout, + status, + tenantName, + tenants: tenantOptions, + activeTenantId: currentTenantId, + onSelectTenant: handleTenantSelect, + onOpenSettings: openSettingsModal, + }), + [ + activeCorrespondentFilters, + activeTagFilters, + appStatus, + clearFilters, + correspondents, + currentTenantId, + documentTypes, + folderClickHandlers, + folderNodes, + handleFolderDelete, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderRename, + handleLogout, + handleSearchChange, + handleSearchSubmit, + handleTenantSelect, + loading, + openSettingsModal, + previewActive, + searchQuery, + draggedFolderId, + isFilterActive, + selectedFolder, + status, + tags, + tenantOptions, + tenantName, + toggleCorrespondentFilter, + toggleTagFilter, + handleTagCreate, + handleCorrespondentCreate, + toggleDocumentTypeFilter, + handleDocumentTypeCreate, + handlePromptCreateFolder, + creatingFolder, + handleNeutralHueChange, + neutralHue, + activeDocumentTypeFilters, + ], + ); + + const handleDetailPanelClose = useCallback(() => { + setDetailPanelOpen(false); + clearDocumentSelection(); + setDetailPanelDocIds([]); + setDetailPanelDocs([]); + }, [clearDocumentSelection]); + + const detailPanelProps = useMemo( + () => ({ + selectedDocuments: detailPanelSelectedDocuments, + tags, + tagLookupById, + onTagAdd: handleTagAdd, + onTagRemove: handleTagRemove, + onRegenerateThumbnails: handleThumbnailRegeneration, + previewEntry: selectedPreviewEntry, + onOpenPreview: openDocumentPreview, + onBulkTagAdd: handleBulkTagAddFromDetail, + onBulkTagRemove: handleBulkTagRemoveFromDetail, + onBulkReanalyze: handleBulkSelectionReanalyze, + onBulkCorrespondentAdd: handleBulkCorrespondentAdd, + onBulkCorrespondentRemove: handleBulkCorrespondentRemove, + onPromoteSelection: promoteSelectionOrder, + activePreviewId, + onUpdateTitle: handleDocumentTitleUpdate, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + correspondents, + onCorrespondentAdd: handleCorrespondentAdd, + onCorrespondentRemove: handleCorrespondentRemove, + documentTypes, + onDocumentTypeSet: handleDocumentTypeSet, + onDocumentTypeClear: handleDocumentTypeClear, + onBulkDocumentTypeSet: handleBulkDocumentTypeSet, + onBulkDocumentTypeClear: handleBulkDocumentTypeClear, + resolveApiPath, + onFolderNavigate: selectFolder, + onClose: handleDetailPanelClose, + resolveFolderPath, + }), + [ + activePreviewId, + correspondents, + detailPanelSelectedDocuments, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkDocumentTypeSet, + handleBulkDocumentTypeClear, + handleBulkSelectionReanalyze, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleCorrespondentAdd, + handleCorrespondentRemove, + handleDocumentTypeSet, + handleDocumentTypeClear, + handleDetailPanelClose, + handleDocumentTitleUpdate, + handleTagAdd, + handleTagRemove, + handleThumbnailRegeneration, + openDocumentPreview, + promoteSelectionOrder, + resolveFolderPath, + selectFolder, + selectedPreviewEntry, + tags, + tagLookupById, + documentTypes, + ], + ); + + const skeuoWorkspaceProps = useMemo( + () => ({ + documents, + searchResults, + breadcrumbs, + currentFolderName, + onExit: exitSkeuoWorkspace, + onRefresh: refreshCurrentFolder, + onDocumentOpen: openDocumentPreview, + resolveThumbnailUrl: resolveThumbnailUrlForDoc, + onAssignTagToDocument: handleDocumentTagAttach, + onRemoveTagFromDocument: handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + activeTagIds: activeTagFilters, + }), + [ + documents, + searchResults, + breadcrumbs, + currentFolderName, + exitSkeuoWorkspace, + refreshCurrentFolder, + openDocumentPreview, + resolveThumbnailUrlForDoc, + handleDocumentTagAttach, + handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + ], + ); + + const contextValue = useMemo( + () => ({ + token, + appStatus, + status, + setStatusMessage, + dropOverlayState, + handleLogout, + sidebarProps, + tags, + refreshTags, + handleTagUpdate, + handleTagDelete, + handleDocumentTagAttach, + correspondents, + refreshCorrespondents, + handleCorrespondentUpdate, + handleCorrespondentCreate, + handleCorrespondentDelete, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, + documentTypes, + refreshDocumentTypes, + handleDocumentTypeUpdate, + handleDocumentTypeCreate, + handleDocumentTypeDelete, + handleDocumentTypeAssign, + handleDocumentTypeClear, + handleDocumentTypeSet, + handleBulkDocumentTypeSet, + handleBulkDocumentTypeClear, + previewActive, + previewWorkspaceDocument, + previewWorkspaceEntry, + closeDocumentPreview, + handleThumbnailRegeneration, + documentsTableProps, + detailPanelProps, + workspaceMode, + showSkeuoWorkspace, + exitSkeuoWorkspace, + skeuoWorkspaceProps, + ensurePreviewData, + ensureAssetUrl, + resolveApiPath, + getDocumentAsset, + notifyApiError, + openTagsModal, + openCorrespondentsModal, + openDocumentTypesModal, + openSettingsModal, + detailPanelOpen, + setDetailPanelOpen, + }), + [ + token, + appStatus, + status, + setStatusMessage, + dropOverlayState, + handleLogout, + sidebarProps, + tags, + refreshTags, + handleTagUpdate, + handleTagDelete, + handleDocumentTagAttach, + correspondents, + refreshCorrespondents, + handleCorrespondentUpdate, + handleCorrespondentCreate, + handleCorrespondentDelete, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, + documentTypes, + refreshDocumentTypes, + handleDocumentTypeUpdate, + handleDocumentTypeCreate, + handleDocumentTypeDelete, + handleDocumentTypeAssign, + handleDocumentTypeClear, + handleDocumentTypeSet, + handleBulkDocumentTypeSet, + handleBulkDocumentTypeClear, + previewActive, + previewWorkspaceDocument, + previewWorkspaceEntry, + closeDocumentPreview, + handleThumbnailRegeneration, + documentsTableProps, + detailPanelProps, + workspaceMode, + showSkeuoWorkspace, + exitSkeuoWorkspace, + skeuoWorkspaceProps, + ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + openTagsModal, + openCorrespondentsModal, + openDocumentTypesModal, + openSettingsModal, + detailPanelOpen, + setDetailPanelOpen, + ], + ); + + if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + const shouldRememberLastLocation = appStatus !== 'logged-out'; + return ( + + ); + } + + return ( + +
+ + + {isTagsModalOpen && ( +
+
event.stopPropagation()} + > +
+

Manage Tags

+ +
+
+ +
+
+
+ )} + {isCorrespondentsModalOpen && ( +
+
event.stopPropagation()} + > +
+

Manage Correspondents

+ +
+
+ +
+
+
+)} + {isDocumentTypesModalOpen && ( +
+
event.stopPropagation()} + > +
+

Manage Document Types

+ +
+
+ +
+
+
+ )} + +
+
+ ); +}; + + +export default AppLayout; diff --git a/frontend/src/app/AppRouter.jsx b/frontend/src/app/AppRouter.jsx new file mode 100644 index 0000000..fb5488b --- /dev/null +++ b/frontend/src/app/AppRouter.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import AppLayout from './AppLayout'; +import DocumentsRoute from './DocumentsRoute'; +import LoginRoute from './LoginRoute'; + +const AppRouter = () => ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + +); + +export default AppRouter; diff --git a/frontend/src/app/DocumentsLayout.jsx b/frontend/src/app/DocumentsLayout.jsx new file mode 100644 index 0000000..f83fad9 --- /dev/null +++ b/frontend/src/app/DocumentsLayout.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import Sidebar from '../sidebar/Sidebar'; + +const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => ( +
+ {!sidebarCollapsed ? : null} + {children} +
+); + +export default DocumentsLayout; diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx new file mode 100644 index 0000000..7fdef3a --- /dev/null +++ b/frontend/src/app/DocumentsRoute.jsx @@ -0,0 +1,204 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronsRightIcon } from '../ui/icons'; +import { createDocumentsSurface } from '../documents/DocumentsTable'; +import { createPreviewSurface } from '../preview/PreviewWorkspace'; +import { createDesktopSurface } from '../DesktopWorkspace'; +import { useAppShell } from '../appShellContext'; +import DocumentsLayout from './DocumentsLayout'; + +const DocumentsRoute = () => { + const { + sidebarProps, + documentsTableProps, + detailPanelProps, + detailPanelOpen, + workspaceMode, + skeuoWorkspaceProps, + openTagsModal, + openCorrespondentsModal, + openDocumentTypesModal, + previewWorkspaceDocument, + previewWorkspaceEntry, + closeDocumentPreview, + handleThumbnailRegeneration, + ensurePreviewData, + resolveApiPath, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + } = useAppShell(); + const navigate = useNavigate(); + + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []); + const expandSidebar = useCallback(() => setSidebarCollapsed(false), []); + + const sidebarPropsWithActions = useMemo( + () => ({ + ...sidebarProps, + onManageTags: openTagsModal, + onManageCorrespondents: openCorrespondentsModal, + onManageDocumentTypes: openDocumentTypesModal, + onCollapse: collapseSidebar, + }), + [sidebarProps, openTagsModal, openCorrespondentsModal, openDocumentTypesModal, collapseSidebar], + ); + + const breadcrumbs = documentsTableProps?.breadcrumbs || null; + const parentBreadcrumb = useMemo(() => { + if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) { + return null; + } + return breadcrumbs[breadcrumbs.length - 2]; + }, [breadcrumbs]); + + const handleNavigateParent = useCallback(() => { + if (!parentBreadcrumb) { + return; + } + const target = parentBreadcrumb.id === 'root' + ? '/documents' + : `/documents/folder/${parentBreadcrumb.id}`; + navigate(target); + }, [navigate, parentBreadcrumb]); + + const isWorkspace = workspaceMode === 'skeuo'; + const isPreviewWorkspace = Boolean(previewWorkspaceDocument); + const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace; + + const renderSidebarToggle = useCallback(() => { + if (!sidebarCollapsed) { + return null; + } + return ( + + ); + }, [sidebarCollapsed, expandSidebar]); + + const documentsSurface = useMemo(() => { + if (!documentsTableProps) { + return null; + } + return createDocumentsSurface({ + tableProps: documentsTableProps, + parentBreadcrumb, + onNavigateParent: parentBreadcrumb ? handleNavigateParent : null, + renderSidebarToggle, + detailProps: detailPanelProps, + detailOpen: detailPanelOpen, + }); + }, [ + documentsTableProps, + parentBreadcrumb, + handleNavigateParent, + renderSidebarToggle, + detailPanelProps, + detailPanelOpen, + ]); + + const previewSurface = useMemo(() => { + if (!showPreviewWorkspace || !previewWorkspaceDocument) { + return null; + } + return createPreviewSurface({ + document: previewWorkspaceDocument, + previewEntry: previewWorkspaceEntry, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + resolveApiPath, + notifyApiError, + onRegenerate: handleThumbnailRegeneration, + onClose: closeDocumentPreview, + renderSidebarToggle, + }); + }, [ + showPreviewWorkspace, + previewWorkspaceDocument, + previewWorkspaceEntry, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + resolveApiPath, + notifyApiError, + handleThumbnailRegeneration, + closeDocumentPreview, + renderSidebarToggle, + ]); + + const workspaceSurface = useMemo(() => { + if (!isWorkspace) { + return null; + } + return createDesktopSurface({ + workspaceProps: skeuoWorkspaceProps, + renderSidebarToggle, + }); + }, [isWorkspace, skeuoWorkspaceProps, renderSidebarToggle]); + + const surface = showPreviewWorkspace + ? previewSurface + : isWorkspace + ? workspaceSurface + : documentsSurface; + + if (!surface) { + return ( + +
+
+
+ + ); + } + + const variant = surface.variant || 'documents'; + + const mainContentClass = `main-content main-content--${variant}${ + surface.detail ? ' main-content--has-detail' : '' + }`; + const bodyClass = `main-content__body main-content__body--${variant}${ + surface.detail ? ' main-content__body--has-detail' : '' + }`; + + const header = surface.header || null; + + return ( + +
+ {header ? ( +
+ {header.leading} +

+ {header.title} + {header.subtitle ? ( + {header.subtitle} + ) : null} +

+
+ {header.actions} +
+ ) : null} +
{surface.content}
+ {surface.detail || null} +
+ + ); +}; + +export default DocumentsRoute; diff --git a/frontend/src/app/DropOverlay.jsx b/frontend/src/app/DropOverlay.jsx new file mode 100644 index 0000000..65cc84a --- /dev/null +++ b/frontend/src/app/DropOverlay.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const DropOverlay = ({ active, folderName }) => ( +
+
+ Drop files to upload to {folderName || 'this location'} +
+
+); + +export default DropOverlay; diff --git a/frontend/src/app/LoginRoute.jsx b/frontend/src/app/LoginRoute.jsx new file mode 100644 index 0000000..027164a --- /dev/null +++ b/frontend/src/app/LoginRoute.jsx @@ -0,0 +1,278 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import LoginView from '../login/LoginView'; +import useApiError from '../hooks/useApiError'; +import { + isWebAuthnAvailable, + preparePublicKeyRequestOptions, + preparePublicKeyCreationOptions, + serializeAuthenticationCredential, + serializeRegistrationCredential, +} from '../utils/webauthn'; +import { api, useAppDispatch, useAppState } from './appState'; + +const LoginRoute = () => { + const { status: appStatus, tenantSelection } = useAppState(); + const appDispatch = useAppDispatch(); + const location = useLocation(); + const [status, setStatus] = useState(null); + const [selectingTenantId, setSelectingTenantId] = useState(null); + const passkeySupported = isWebAuthnAvailable(); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const signupSupported = passkeySupported; + const [signupLoading, setSignupLoading] = useState(false); + + const setStatusMessage = useCallback((message, variant = 'info') => { + setStatus(message ? { message, variant } : null); + }, []); + + const handleLoginApiReport = useCallback( + ({ message, variant }) => setStatusMessage(message, variant), + [setStatusMessage], + ); + + const reportLoginError = useApiError({ + onReport: handleLoginApiReport, + }); + + const notifyLoginError = useCallback( + (error, fallbackMessage, variant = 'error') => + reportLoginError(error, { message: fallbackMessage, variant }), + [reportLoginError], + ); + + const handleTenantSelect = useCallback( + async (tenant) => { + if (!tenantSelection?.selectionToken || !tenant?.id) { + return; + } + + try { + setSelectingTenantId(tenant.id); + const { data } = await api.post( + '/auth/select-tenant', + { tenant_id: tenant.id }, + { + headers: { + Authorization: `Bearer ${tenantSelection.selectionToken}`, + }, + }, + ); + + if (!data?.access_token) { + throw new Error('Invalid tenant selection response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + const message = error?.response?.data?.error || 'Failed to finalize login.'; + notifyLoginError(error, message, 'error'); + } finally { + setSelectingTenantId(null); + } + }, + [appDispatch, notifyLoginError, setStatusMessage, tenantSelection], + ); + + const handleCancelSelection = useCallback(() => { + appDispatch({ type: 'CLEAR_TENANT_SELECTION' }); + setStatusMessage(null); + }, [appDispatch, setStatusMessage]); + + const handlePasskeyLogin = useCallback( + async (rawUsername) => { + const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; + if (!username) { + setStatusMessage('Enter your username before using a passkey.', 'error'); + return; + } + if (!passkeySupported) { + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return; + } + + setPasskeyLoading(true); + appDispatch({ type: 'LOGIN_REQUEST' }); + try { + const { data: startData } = await api.post('/auth/passkeys/login/start', { username }); + const challengeId = startData.challengeId; + const publicKeyOptions = startData.publicKey; + + if (!challengeId || !publicKeyOptions) { + throw new Error('Invalid passkey challenge response.'); + } + + const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions }); + setStatusMessage('Confirm the passkey prompt to continue.', 'info'); + const assertion = await navigator.credentials.get({ publicKey }); + + if (!assertion) { + setStatusMessage('Passkey login cancelled.', 'info'); + return; + } + + const serialized = serializeAuthenticationCredential(assertion); + const finishPayload = { + challengeId, + credential: serialized, + }; + + const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload); + + if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: finishData.access_token, + tenants: finishData.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!finishData?.access_token) { + throw new Error('Invalid login response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: finishData.access_token, + tenant: finishData.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Passkey login cancelled.', 'info'); + } else if (error?.response?.status === 404) { + setStatusMessage('No passkey registered for this username. Create an account first.', 'error'); + appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' }); + } else if (error?.response?.status === 400 && error?.response?.data?.error) { + setStatusMessage(error.response.data.error, 'error'); + appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error }); + } else { + const message = error?.response?.data?.error || error.message || 'Passkey login failed.'; + notifyLoginError(error, message); + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + } + } finally { + setPasskeyLoading(false); + } + }, + [appDispatch, notifyLoginError, passkeySupported, setStatusMessage], + ); + + const handleSignup = useCallback( + async (rawUsername) => { + const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; + if (!username) { + setStatusMessage('Choose a username to create your account.', 'error'); + return; + } + if (!passkeySupported) { + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return; + } + + setSignupLoading(true); + try { + const { data: startData } = await api.post('/auth/signup/start', { username }); + const signupToken = startData.signup_token; + const challengePayload = startData.challenge; + const challengeId = challengePayload?.challengeId; + const publicKeyOptions = challengePayload?.publicKey; + + if (!signupToken || !challengeId || !publicKeyOptions) { + throw new Error('Invalid signup challenge response.'); + } + + const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); + setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info'); + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + setStatusMessage('Signup cancelled.', 'info'); + return; + } + + const serialized = serializeRegistrationCredential(credential); + const finishPayload = { + signup_token: signupToken, + credential: serialized, + }; + + const { data: finishData } = await api.post('/auth/signup/finish', finishPayload); + + if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: finishData.access_token, + tenants: finishData.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!finishData?.access_token) { + throw new Error('Invalid signup response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: finishData.access_token, + tenant: finishData.tenant || null, + }); + setStatusMessage('Account created. Welcome!', 'success'); + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Signup cancelled.', 'info'); + } else if (error?.response?.status === 409) { + setStatusMessage('This passkey is already registered. Try signing in instead.', 'error'); + } else if (error?.response?.data?.error) { + setStatusMessage(error.response.data.error, 'error'); + } else { + const message = error?.message || 'Failed to create account.'; + setStatusMessage(message, 'error'); + } + } finally { + setSignupLoading(false); + } + }, + [appDispatch, passkeySupported, setStatusMessage], + ); + + const redirectTarget = useMemo(() => { + const target = location.state?.from; + if (typeof target === 'string' && target.startsWith('/')) { + return target; + } + return '/documents'; + }, [location.state]); + + if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + return ; + } + + return ( +
+ +
+ ); +}; + +export default LoginRoute; diff --git a/frontend/src/app/appState.js b/frontend/src/app/appState.js new file mode 100644 index 0000000..a9b168a --- /dev/null +++ b/frontend/src/app/appState.js @@ -0,0 +1,240 @@ +import React, { useContext, useEffect, useMemo, useReducer } from 'react'; +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', + withCredentials: true, +}); + +const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined; + +const STORED_TOKEN = storage?.getItem('papercrate_token') ?? ''; +let STORED_TENANT = null; + +if (storage) { + try { + const rawTenant = storage.getItem('papercrate_tenant'); + if (rawTenant) { + STORED_TENANT = JSON.parse(rawTenant); + } + } catch (error) { + console.warn('[app] Failed to parse stored tenant metadata', error); + } +} + +if (STORED_TOKEN) { + api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; +} + +const initialAppState = { + status: STORED_TOKEN ? 'authenticated' : 'logged-out', + token: STORED_TOKEN, + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: STORED_TENANT, + tenants: [], +}; + +const AppStateContext = React.createContext(null); +const AppDispatchContext = React.createContext(null); + +const appStateReducer = (state, action) => { + switch (action.type) { + case 'LOGIN_REQUEST': + return { + ...state, + status: 'authenticating', + error: null, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'LOGIN_SUCCESS': + return { + ...state, + status: 'authenticated', + token: action.token, + error: null, + tenantSelection: null, + tenant: action.tenant || null, + tenants: state.tenants, + }; + case 'LOGIN_FAILURE': + return { + status: 'logged-out', + token: '', + error: action.error || null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'TENANT_SELECTION_REQUIRED': + return { + status: 'selecting-tenant', + token: '', + error: null, + isRefreshing: false, + tenantSelection: { + selectionToken: action.selectionToken, + tenants: action.tenants, + }, + tenant: null, + tenants: [], + }; + case 'CLEAR_TENANT_SELECTION': + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'LOGOUT_SUCCESS': + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'BOOTSTRAP_START': + return { ...state, status: 'bootstrapping', error: null }; + case 'BOOTSTRAP_SUCCESS': + return { ...state, status: 'ready', error: null }; + case 'BOOTSTRAP_FAILURE': + return { ...state, status: 'authenticated', error: action.error || null }; + case 'TOKEN_REFRESH_START': + return { ...state, isRefreshing: true, error: null }; + case 'TOKEN_REFRESH_SUCCESS': + return { + ...state, + token: action.token, + isRefreshing: false, + status: state.status === 'logged-out' ? 'authenticated' : state.status, + tenantSelection: null, + tenant: action.tenant || state.tenant || null, + tenants: state.tenants, + }; + case 'TOKEN_REFRESH_FAILURE': + return { + status: 'logged-out', + token: '', + error: action.error || null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'LOGOUT': + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'RESET_ERROR': + return { ...state, error: null }; + case 'SET_TENANTS': + return { + ...state, + tenants: Array.isArray(action.tenants) ? action.tenants : [], + }; + default: + return state; + } +}; + +const AppStateProvider = ({ children }) => { + const [state, dispatch] = useReducer(appStateReducer, initialAppState); + + useEffect(() => { + const token = state.token ?? ''; + if (token) { + api.defaults.headers.common.Authorization = `Bearer ${token}`; + storage?.setItem('papercrate_token', token); + } else { + delete api.defaults.headers.common.Authorization; + storage?.removeItem('papercrate_token'); + } + }, [state.token]); + + useEffect(() => { + if (state.tenant) { + try { + storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant)); + } catch (error) { + console.warn('[app] Failed to persist tenant info', error); + } + } else { + storage?.removeItem('papercrate_tenant'); + } + }, [state.tenant]); + + useEffect(() => { + let abort = false; + + const loadTenants = async () => { + if (state.status !== 'authenticated' || !state.token) { + dispatch({ type: 'SET_TENANTS', tenants: [] }); + return; + } + + try { + const { data } = await api.get('/auth/tenants'); + if (!abort) { + dispatch({ + type: 'SET_TENANTS', + tenants: Array.isArray(data?.tenants) ? data.tenants : [], + }); + } + } catch (error) { + if (!abort) { + console.warn('Failed to load tenant list', error); + } + } + }; + + loadTenants(); + + return () => { + abort = true; + }; + }, [state.status, state.token, dispatch]); + + const stateValue = useMemo(() => state, [state]); + + return ( + + + {children} + + + ); +}; + +const useAppState = () => { + const context = useContext(AppStateContext); + if (!context) { + throw new Error('useAppState must be used within an AppStateProvider.'); + } + return context; +}; + +const useAppDispatch = () => { + const context = useContext(AppDispatchContext); + if (!context) { + throw new Error('useAppDispatch must be used within an AppStateProvider.'); + } + return context; +}; + +export { api, AppStateProvider, useAppState, useAppDispatch }; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 6990eb6..78a38a0 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1,6363 +1,11 @@ import '@fontsource/inter/400.css'; -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - useReducer, -} from 'react'; +import React from 'react'; import { createRoot } from 'react-dom/client'; -import axios from 'axios'; -import { - HashRouter, - Navigate, - Route, - Routes, - Outlet, - useLocation, - useNavigate, - useMatch, - matchPath, -} from 'react-router-dom'; +import { HashRouter } from 'react-router-dom'; import './styles.css'; -import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager'; -import useApiError from './hooks/useApiError'; -import TagsPanel from './tags/TagsPanel'; -import CorrespondentsPanel from './correspondents/CorrespondentsPanel'; -import DocumentTypesPanel from './documentTypes/DocumentTypesPanel'; -import TagManager from './tag_manager'; -import Sidebar from './sidebar/Sidebar'; -import SettingsModal from './settings/SettingsModal'; -import { ChevronsRightIcon } from './ui/icons'; -import { createDocumentsSurface } from './documents/DocumentsTable'; -import { createPreviewSurface } from './preview/PreviewWorkspace'; -import { createDesktopSurface } from './DesktopWorkspace'; -import { AppShellContext, useAppShell } from './appShellContext'; -import LoginView from './login/LoginView'; -import usePasskeys from './settings/usePasskeys'; -import { - isWebAuthnAvailable, - preparePublicKeyRequestOptions, - preparePublicKeyCreationOptions, - serializeAuthenticationCredential, - serializeRegistrationCredential, -} from './utils/webauthn'; - -const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early -const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; - -const api = axios.create({ - baseURL: '/api', - withCredentials: true, -}); - -const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined; -const STORED_TOKEN = storage?.getItem('papercrate_token') ?? ''; -let STORED_TENANT = null; -if (storage) { - try { - const rawTenant = storage.getItem('papercrate_tenant'); - if (rawTenant) { - STORED_TENANT = JSON.parse(rawTenant); - } -} catch (error) { - console.warn('[app] Failed to parse stored tenant metadata', error); -} -} -if (STORED_TOKEN) { - api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; -} - -const initialAppState = { - status: STORED_TOKEN ? 'authenticated' : 'logged-out', - token: STORED_TOKEN, - error: null, - isRefreshing: false, - tenantSelection: null, - tenant: STORED_TENANT, - tenants: [], -}; - -const AppStateContext = React.createContext(null); -const AppDispatchContext = React.createContext(null); - -const appStateReducer = (state, action) => { - switch (action.type) { - case 'LOGIN_REQUEST': - return { - ...state, - status: 'authenticating', - error: null, - tenantSelection: null, - tenant: null, - tenants: [], - }; - case 'LOGIN_SUCCESS': - return { - ...state, - status: 'authenticated', - token: action.token, - error: null, - tenantSelection: null, - tenant: action.tenant || null, - tenants: state.tenants, - }; - case 'LOGIN_FAILURE': - return { - status: 'logged-out', - token: '', - error: action.error || null, - isRefreshing: false, - tenantSelection: null, - tenant: null, - tenants: [], - }; - case 'TENANT_SELECTION_REQUIRED': - return { - status: 'selecting-tenant', - token: '', - error: null, - isRefreshing: false, - tenantSelection: { - selectionToken: action.selectionToken, - tenants: action.tenants, - }, - tenant: null, - tenants: [], - }; - case 'CLEAR_TENANT_SELECTION': - return { - status: 'logged-out', - token: '', - error: null, - isRefreshing: false, - tenantSelection: null, - tenant: null, - tenants: [], - }; - case 'BOOTSTRAP_START': - return { ...state, status: 'bootstrapping', error: null }; - case 'BOOTSTRAP_SUCCESS': - return { ...state, status: 'ready', error: null }; - case 'BOOTSTRAP_FAILURE': - return { ...state, status: 'authenticated', error: action.error || null }; - case 'TOKEN_REFRESH_START': - return { ...state, isRefreshing: true, error: null }; - case 'TOKEN_REFRESH_SUCCESS': - return { - ...state, - token: action.token, - isRefreshing: false, - status: state.status === 'logged-out' ? 'authenticated' : state.status, - tenantSelection: null, - tenant: action.tenant || state.tenant || null, - tenants: state.tenants, - }; - case 'TOKEN_REFRESH_FAILURE': - return { - status: 'logged-out', - token: '', - error: action.error || null, - isRefreshing: false, - tenantSelection: null, - tenant: null, - tenants: [], - }; - case 'LOGOUT': - return { - status: 'logged-out', - token: '', - error: null, - isRefreshing: false, - tenantSelection: null, - tenant: null, - tenants: [], - }; - case 'RESET_ERROR': - return { ...state, error: null }; - case 'SET_TENANTS': - return { - ...state, - tenants: Array.isArray(action.tenants) ? action.tenants : [], - }; - default: - return state; - } -}; - -const AppStateProvider = ({ children }) => { - const [state, dispatch] = useReducer(appStateReducer, initialAppState); - - useEffect(() => { - const token = state.token ?? ''; - if (token) { - api.defaults.headers.common.Authorization = `Bearer ${token}`; - storage?.setItem('papercrate_token', token); - } else { - delete api.defaults.headers.common.Authorization; - storage?.removeItem('papercrate_token'); - } - }, [state.token]); - - useEffect(() => { - if (state.tenant) { - try { - storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant)); - } catch (error) { - console.warn('Failed to persist tenant info', error); - } - } else { - storage?.removeItem('papercrate_tenant'); - } - }, [state.tenant]); - - useEffect(() => { - let abort = false; - const loadTenants = async () => { - if (state.status !== 'authenticated' || !state.token) { - dispatch({ type: 'SET_TENANTS', tenants: [] }); - return; - } - try { - const { data } = await api.get('/auth/tenants'); - if (!abort) { - dispatch({ - type: 'SET_TENANTS', - tenants: Array.isArray(data?.tenants) ? data.tenants : [], - }); - } - } catch (error) { - if (!abort) { - console.warn('Failed to load tenant list', error); - } - } - }; - loadTenants(); - return () => { - abort = true; - }; - }, [state.status, state.token, dispatch]); - - const stateValue = useMemo(() => state, [state]); - - return ( - - - {children} - - - ); -}; - -const useAppState = () => { - const context = useContext(AppStateContext); - if (!context) { - throw new Error('useAppState must be used within an AppStateProvider.'); - } - return context; -}; - -const useAppDispatch = () => { - const context = useContext(AppDispatchContext); - if (!context) { - throw new Error('useAppDispatch must be used within an AppStateProvider.'); - } - return context; -}; - -const DEFAULT_FOLDER_NAME = 'All Documents'; - -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 DropOverlay = ({ active, folderName }) => ( -
-
- Drop files to upload to {folderName} -
-
-); - - - -const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => ( -
- {!sidebarCollapsed ? : null} - {children} -
-); - -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 [isTagsModalOpen, setTagsModalOpen] = useState(false); - const [isCorrespondentsModalOpen, setCorrespondentsModalOpen] = useState(false); - const [isDocumentTypesModalOpen, setDocumentTypesModalOpen] = useState(false); - const [isSettingsModalOpen, setSettingsModalOpen] = 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 [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root'); - const [currentFolder, setCurrentFolder] = useState(null); - const [currentSubfolders, setCurrentSubfolders] = useState([]); - const [documents, setDocuments] = useState([]); - const [workspaceMode, setWorkspaceMode] = useState('table'); - const [documentsViewMode, setDocumentsViewMode] = useState(() => { - if (typeof window === 'undefined') { - return 'list'; - } - const stored = window.localStorage.getItem('papercrate_view_mode'); - return stored === 'grid' ? 'grid' : 'list'; - }); - const [neutralHue, setNeutralHue] = useState(() => { - if (typeof window === 'undefined') { - return 260; - } - const stored = window.localStorage.getItem('papercrate_neutral_hue'); - if (stored) { - const parsedStored = Number.parseInt(stored, 10); - if (!Number.isNaN(parsedStored)) { - return parsedStored; - } - } - const root = document.documentElement; - const current = root.style.getPropertyValue('--neutral-hue'); - const parsed = Number.parseInt(current, 10); - return Number.isNaN(parsed) ? 260 : parsed; - }); - const initialRowSelection = []; - const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); - const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); - const [focusedDocumentId, setFocusedDocumentId] = useState(null); - const [focusedRowKey, setFocusedRowKey] = useState(null); - const tokenRef = useRef(token); - const refreshPromiseRef = useRef(null); - const breadcrumbFetchRef = useRef(new Set()); - const tagRemovalCursorActiveRef = useRef(false); - const tenantIdRef = useRef(currentTenantId); - 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('skeuo-cursor-remove'); - } else { - body.classList.remove('skeuo-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 [tags, setTags] = useState([]); - const [correspondents, setCorrespondents] = useState([]); - const [documentTypes, setDocumentTypes] = useState([]); - const [webdavTokens, setWebdavTokens] = useState([]); - const [webdavTokensLoading, setWebdavTokensLoading] = useState(false); - const [creatingWebdavToken, setCreatingWebdavToken] = useState(false); - const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null); - const [webdavTokenSecret, setWebdavTokenSecret] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [activeTagFilters, setActiveTagFilters] = useState([]); - const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); - const [activeDocumentTypeFilters, setActiveDocumentTypeFilters] = 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) => - previous.includes(tagId) - ? previous.filter((id) => id !== tagId) - : previous.concat([tagId]), - ); - }, []); - - const toggleCorrespondentFilter = useCallback((correspondentId) => { - setActiveCorrespondentFilters((previous) => { - if (!correspondentId) { - return []; - } - return previous.includes(correspondentId) ? [] : [correspondentId]; - }); - }, []); - - const toggleDocumentTypeFilter = useCallback((documentTypeId) => { - setActiveDocumentTypeFilters((previous) => { - if (!documentTypeId) { - return []; - } - return previous.includes(documentTypeId) ? [] : [documentTypeId]; - }); - }, []); - - 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]); - - useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); - } - if (typeof window !== 'undefined') { - window.localStorage.setItem('papercrate_neutral_hue', String(neutralHue)); - } - }, [neutralHue]); - - const handleNeutralHueChange = useCallback((value) => { - if (value === '') { - setNeutralHue(260); - return; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return; - } - setNeutralHue(parsed); - }, []); - - const clearFilters = useCallback(() => { - setSearchQuery(''); - setActiveTagFilters([]); - setActiveCorrespondentFilters([]); - setActiveDocumentTypeFilters([]); - setSearchLoading(false); - }, []); - - 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 getDocumentAsset = useCallback((doc, type) => { - if (!doc || !type) return null; - return getAssetFromVersion(doc.current_version || null, type); - }, []); - - const bootstrapInitializedRef = useRef(false); - const selectionInitializedRef = useRef(false); - const dragCounterRef = useRef(0); - const detailFolderFetchRef = useRef(new Set()); - const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null); - const selectionOrderRef = useRef(initialRowSelection); - - const selectedDocumentIds = useMemo( - () => - selectedRowKeys - .filter(isDocumentRowKey) - .map((key) => getRowId(key)) - .filter(Boolean), - [selectedRowKeys], - ); - - const selectedFolderIds = useMemo( - () => - selectedRowKeys - .filter(isFolderRowKey) - .map((key) => getRowId(key)) - .filter(Boolean), - [selectedRowKeys], - ); - - const [detailPanelOpen, setDetailPanelOpen] = useState(() => selectedDocumentIds.length > 0); - const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); - const [detailPanelDocs, setDetailPanelDocs] = useState([]); - - useEffect(() => { - if (selectedDocumentIds.length > 0 && !detailPanelOpen) { - setDetailPanelOpen(true); - } - }, [selectedDocumentIds, detailPanelOpen]); - - const resetWorkspaceState = useCallback(() => { - const rootNode = createRootNode(); - setFolderNodes(new Map([[rootNode.id, rootNode]])); - setFolderContents(new Map()); - setSelectedFolder('root'); - setCurrentFolder(null); - setCurrentSubfolders([]); - setDocuments([]); - setSelectedRowKeys([]); - setSelectionOrder([]); - selectionOrderRef.current = []; - setFocusedDocumentId(null); - selectionAnchorRef.current = null; - setDraggedDocumentIds([]); - setDraggedFolderId(null); - setSearchResults(null); - setTags([]); - setCorrespondents([]); - setDocumentTypes([]); - setWebdavTokens([]); - setWebdavTokensLoading(false); - setCreatingWebdavToken(false); - setDeletingWebdavTokenId(null); - setWebdavTokenSecret(null); - setSearchQuery(''); - setActiveTagFilters([]); - setActiveCorrespondentFilters([]); - setActiveDocumentTypeFilters([]); - setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); - setActivePreviewId(null); - setDetailPanelOpen(false); - setDetailPanelDocIds([]); - setDetailPanelDocs([]); - 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]); - - 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]); - - const documentTypeLookupByName = useMemo(() => { - const map = new Map(); - documentTypes.forEach((entry) => { - if (entry?.name) { - map.set(entry.name.toLowerCase(), entry); - } - }); - return map; - }, [documentTypes]); - - const documentTypeLookupById = useMemo(() => { - const map = new Map(); - documentTypes.forEach((entry) => { - if (entry?.id) { - map.set(entry.id, entry); - } - }); - return map; - }, [documentTypes]); - - const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => { - const nextSet = new Set(nextSelection); - const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); - const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); - - const base = previousOrder.filter((id) => !interacted.includes(id)); - const result = [...base]; - - interacted.forEach((id) => { - if (nextSet.has(id) && !result.includes(id)) { - result.push(id); - } - }); - - nextSelection.forEach((id) => { - if (!result.includes(id)) { - result.push(id); - } - }); - - if ( - result.length !== selectionOrderRef.current.length || - result.some((id, index) => selectionOrderRef.current[index] !== id) - ) { - selectionOrderRef.current = result; - setSelectionOrder(result); - } else { - selectionOrderRef.current = result; - } - }, []); - - useEffect(() => { - if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { - resetWorkspaceState(); - } - }, [appStatus, resetWorkspaceState]); - - 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]); - - 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 || - activeDocumentTypeFilters.length > 0, - [searchQuery, activeTagFilters, activeCorrespondentFilters, activeDocumentTypeFilters], - ); - - 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 = []; - - setSelectedRowKeys((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, 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 removeDocumentFromCaches = useCallback( - (documentId) => { - if (!documentId) { - return; - } - - const removeFromList = (list) => { - const next = list.filter((doc) => doc.id !== documentId); - return next.length === list.length ? list : next; - }; - - setDocuments((prev) => removeFromList(prev)); - setSearchResults((prev) => (Array.isArray(prev) ? removeFromList(prev) : 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; - } - const filteredDocs = docs.filter((doc) => doc.id !== documentId); - if (filteredDocs.length !== docs.length) { - changed = true; - next.set(key, { ...contents, documents: filteredDocs }); - } else { - next.set(key, contents); - } - }); - return changed ? next : prev; - }); - }, - [setDocuments, setSearchResults, setFolderContents], - ); - - const applySelection = useCallback( - (rowKeys, { anchor, interactedKeys = [] } = {}) => { - const unique = []; - - (rowKeys || []).forEach((key) => { - if (!key) return; - const canonicalKey = visibleRowKeySet.has(key) - ? key - : isDocumentRowKey(key) - ? resolveDocumentRowKey(getRowId(key)) - : isFolderRowKey(key) - ? resolveFolderRowKey(getRowId(key)) - : null; - - if (!canonicalKey || !visibleRowKeySet.has(canonicalKey)) { - return; - } - - if (!unique.includes(canonicalKey)) { - unique.push(canonicalKey); - } - }); - - let resolvedAnchor = anchor; - if (resolvedAnchor && !unique.includes(resolvedAnchor)) { - resolvedAnchor = null; - } - - setSelectedRowKeys(unique); - updateSelectionOrder(unique, interactedKeys); - - const nextFocusedDocumentId = (() => { - if (focusedDocumentId) { - const focusKey = resolveDocumentRowKey(focusedDocumentId); - if (focusKey && unique.includes(focusKey)) { - return focusedDocumentId; - } - } - - if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { - return getRowId(resolvedAnchor) || null; - } - - const lastDocKey = [...unique].reverse().find(isDocumentRowKey); - return lastDocKey ? getRowId(lastDocKey) || null : null; - })(); - - setFocusedDocumentId(nextFocusedDocumentId); - - if (resolvedAnchor) { - selectionAnchorRef.current = resolvedAnchor; - } else if (!unique.length) { - selectionAnchorRef.current = null; - } else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) { - selectionAnchorRef.current = unique[unique.length - 1]; - } - - return { selection: unique, focusKey: selectionAnchorRef.current }; - }, - [visibleRowKeySet, focusedDocumentId, updateSelectionOrder], - ); - - const promoteSelectionOrder = useCallback( - (docId) => { - if (!docId) return; - const rowKey = resolveDocumentRowKey(docId); - if (!rowKey) return; - if (!selectedRowKeys.includes(rowKey)) return; - updateSelectionOrder(selectedRowKeys, [rowKey]); - selectionAnchorRef.current = rowKey; - setFocusedDocumentId(docId); - setActivePreviewId(docId); - }, - [selectedRowKeys, updateSelectionOrder], - ); - - - 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], - ); - - const handleRowSelection = useCallback( - (rowKey, event) => { - if (!rowKey || !visibleRowKeySet.has(rowKey)) { - return; - } - - setFocusedRowKey(rowKey); - - const shiftKey = Boolean(event?.shiftKey); - const metaKey = Boolean(event?.metaKey); - const ctrlKey = Boolean(event?.ctrlKey); - const additive = metaKey || ctrlKey; - - if (shiftKey) { - event?.preventDefault?.(); - } - - let anchorKey = selectionAnchorRef.current; - if (!anchorKey && shiftKey && selectedRowKeys.length) { - anchorKey = selectedRowKeys[selectedRowKeys.length - 1]; - } - if (!anchorKey) { - anchorKey = rowKey; - } - - let nextKeys = []; - let interactedKeys = []; - - if (shiftKey && anchorKey) { - const anchorIndex = navigableRowKeys.indexOf(anchorKey); - const targetIndex = navigableRowKeys.indexOf(rowKey); - if (anchorIndex !== -1 && targetIndex !== -1) { - const [start, end] = - anchorIndex <= targetIndex - ? [anchorIndex, targetIndex] - : [targetIndex, anchorIndex]; - const range = navigableRowKeys.slice(start, end + 1); - nextKeys = range; - - const previousSet = new Set(selectedRowKeys); - interactedKeys = range.filter( - (key) => key === rowKey || !previousSet.has(key), - ); - if (!interactedKeys.includes(rowKey)) { - interactedKeys.push(rowKey); - } - } else { - nextKeys = [rowKey]; - interactedKeys = [rowKey]; - } - } else if (additive) { - if (selectedRowKeys.includes(rowKey)) { - nextKeys = selectedRowKeys.filter((key) => key !== rowKey); - interactedKeys = []; - } else { - nextKeys = [...selectedRowKeys, rowKey]; - interactedKeys = [rowKey]; - } - anchorKey = rowKey; - } else { - nextKeys = [rowKey]; - interactedKeys = [rowKey]; - anchorKey = rowKey; - } - - applySelection(nextKeys, { anchor: anchorKey, interactedKeys }); - }, - [applySelection, navigableRowKeys, selectedRowKeys, setFocusedRowKey, visibleRowKeySet], - ); - - const handleDocumentRowClick = useCallback( - (documentId, event) => { - const rowKey = resolveDocumentRowKey(documentId); - if (!rowKey) return; - handleRowSelection(rowKey, event); - }, - [handleRowSelection], - ); - - const clearDocumentSelection = useCallback(() => { - setFocusedRowKey(null); - applySelection([], { anchor: null, interactedKeys: [] }); - }, [applySelection]); - - const handleFolderRowClick = useCallback( - (folderId, event) => { - const rowKey = resolveFolderRowKey(folderId); - if (!rowKey) return; - handleRowSelection(rowKey, event); - }, - [handleRowSelection], - ); - - 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]); - - useEffect(() => { - if (!focusedRowKey) { - return; - } - if (navigableRowKeys.includes(focusedRowKey)) { - return; - } - const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null; - if (docKey && navigableRowKeys.includes(docKey)) { - setFocusedRowKey(docKey); - return; - } - if (navigableRowKeys.length) { - setFocusedRowKey(navigableRowKeys[0]); - } else { - setFocusedRowKey(null); - } - }, [focusedRowKey, navigableRowKeys, focusedDocumentId]); - - - const ensureFolderData = useCallback( - async ( - folderId, - { force = false, includeDocuments = true, prefetchDepth = 0 } = {}, - ) => { - const requestTenantId = tenantIdRef.current; - const cached = folderContents.get(folderId); - if (!force && cached) { - const includesDocuments = Boolean(cached.__includesDocuments); - if (!includeDocuments || includesDocuments) { - 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 = includeDocuments - ? undefined - : { include_documents: false }; - const { data } = await api.get(`/folders/${path}/contents`, { - params, - }); - const hydrated = assetManager.hydrateFolderContents(data); - const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; - const childIds = childFolders.map((child) => child.id); - - if (tenantIdRef.current !== requestTenantId) { - return { ...hydrated, __includesDocuments: includeDocuments }; - } - - 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, - }), - ), - ); - } - - const enriched = { - ...hydrated, - __includesDocuments: includeDocuments, - }; - - 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, - }); - } else { - next.set(folderId, enriched); - } - return next; - }); - } - - return enriched; - }, - [assetManager, folderContents], - ); - - 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 refreshDocumentTypes = useCallback(async () => { - const requestTenantId = tenantIdRef.current; - try { - const { data } = await api.get('/document-types'); - if (tenantIdRef.current !== requestTenantId) { - return; - } - setDocumentTypes(data || []); - } catch (error) { - if (tenantIdRef.current !== requestTenantId) { - return; - } - notifyApiError(error, 'Unable to load document types.'); - } - }, [notifyApiError]); - - const refreshWebdavTokens = useCallback(async () => { - if (!token) { - return; - } - setWebdavTokensLoading(true); - try { - const { data } = await api.get('/profile/webdav-tokens'); - setWebdavTokens(Array.isArray(data) ? data : []); - } catch (error) { - notifyApiError(error, 'Failed to load WebDAV tokens.'); - } finally { - setWebdavTokensLoading(false); - } - }, [notifyApiError, token]); - - const createWebdavToken = useCallback( - async ({ label, expires_at } = {}) => { - if (creatingWebdavToken) { - return false; - } - setCreatingWebdavToken(true); - try { - const payload = {}; - if (label) { - payload.label = label; - } - if (expires_at) { - payload.expires_at = expires_at; - } - const { data } = await api.post('/profile/webdav-tokens', payload); - if (data?.token_info) { - setWebdavTokens((previous) => { - const filtered = previous.filter((entry) => entry.id !== data.token_info.id); - return [data.token_info, ...filtered]; - }); - } else { - await refreshWebdavTokens(); - } - if (data?.token) { - setWebdavTokenSecret(data.token); - } - setStatusMessage('WebDAV token created.', 'success'); - return data; - } catch (error) { - notifyApiError(error, 'Failed to create WebDAV token.'); - return false; - } finally { - setCreatingWebdavToken(false); - } - }, - [creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage], - ); - - const deleteWebdavToken = useCallback( - async (tokenId) => { - if (!tokenId) { - return false; - } - setDeletingWebdavTokenId(tokenId); - try { - await api.delete(`/profile/webdav-tokens/${tokenId}`); - await refreshWebdavTokens(); - setStatusMessage('WebDAV token revoked.', 'success'); - return true; - } catch (error) { - notifyApiError(error, 'Failed to revoke WebDAV token.'); - return false; - } finally { - setDeletingWebdavTokenId(null); - } - }, - [refreshWebdavTokens, notifyApiError, setStatusMessage], - ); - - const dismissCreatedWebdavToken = useCallback(() => { - setWebdavTokenSecret(null); - }, []); - - const { - passkeys, - passkeysSupported, - passkeysLoading, - registeringPasskey, - revokingPasskeyId, - refreshPasskeys, - registerPasskey, - revokePasskey, - } = usePasskeys({ - api, - notifyApiError, - setStatusMessage, - token, - }); - - useEffect(() => { - if (!isSettingsModalOpen) { - return; - } - refreshWebdavTokens(); - refreshPasskeys(); - }, [isSettingsModalOpen, refreshPasskeys, refreshWebdavTokens]); - - useEffect(() => { - if (!isSettingsModalOpen) { - setWebdavTokenSecret(null); - } - }, [isSettingsModalOpen]); - - 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]); - - 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 }) => { - 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 = 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 handleDocumentTypeUpdate = useCallback( - async (documentTypeId, changes) => { - if (!documentTypeId) { - throw new Error('Missing document type identifier.'); - } - - const payload = {}; - if (typeof changes.name === 'string') { - const trimmed = changes.name.trim(); - if (!trimmed) { - throw new Error('Document type name cannot be empty.'); - } - payload.name = trimmed; - } - - if (Object.keys(payload).length === 0) { - return false; - } - - try { - await api.patch(`/document-types/${documentTypeId}`, payload); - await refreshDocumentTypes(); - setStatusMessage('Document type updated.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update document type.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshDocumentTypes, notifyApiError, setStatusMessage], - ); - - const handleDocumentTypeCreate = useCallback( - async ({ name }) => { - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - throw new Error('Document type name is required.'); - } - try { - const { data } = await api.post('/document-types', { name: trimmed }); - await refreshDocumentTypes(); - setStatusMessage('Document type created.', 'success'); - return data; - } catch (error) { - const message = error.response?.data?.error || 'Failed to create document type.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshDocumentTypes, notifyApiError, setStatusMessage], - ); - - const handleDocumentTypeDelete = useCallback( - async (documentTypeId) => { - if (!documentTypeId) { - throw new Error('Missing document type identifier.'); - } - - const deletedEntry = documentTypeLookupById.get(documentTypeId); - - try { - await api.delete(`/document-types/${documentTypeId}`); - - mapDocumentCaches((doc) => { - if (!doc) { - return doc; - } - - const existingType = doc.document_type; - const existingTypeId = doc.document_type_id ?? existingType?.id ?? null; - const existingTypeName = typeof existingType?.name === 'string' ? existingType.name : undefined; - - const shouldClear = - existingTypeId === documentTypeId || - (!!deletedEntry?.name && - existingTypeName && - existingTypeName.toLowerCase() === deletedEntry.name.toLowerCase()); - - if (!shouldClear) { - return doc; - } - - return { ...doc, document_type: null, document_type_id: null }; - }); - - await refreshDocumentTypes(); - setStatusMessage('Document type deleted.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete document type.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [ - refreshDocumentTypes, - notifyApiError, - setStatusMessage, - mapDocumentCaches, - documentTypeLookupById, - ], - ); - - const handleDocumentTypeAssign = useCallback( - async ({ documentId, documentTypeId, documentType }, { notify = true } = {}) => { - if (!documentId) { - throw new Error('Missing document identifier.'); - } - - try { - const payload = { document_type_id: documentTypeId ?? null }; - const { data } = await api.patch(`/documents/${documentId}`, payload); - const updatedDocument = extractDocumentFromResponse(data); - - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - - const next = { ...doc }; - if (documentTypeId) { - const entry = - documentTypeLookupById.get(documentTypeId) || - (documentType?.name ? documentType : null); - next.document_type = entry - ? { id: entry.id ?? documentTypeId, name: entry.name } - : { id: documentTypeId, name: '' }; - } else { - next.document_type = null; - } - next.document_type_id = documentTypeId ?? null; - return next; - }); - - if (notify) { - setStatusMessage( - documentTypeId ? 'Document type updated.' : 'Document type cleared.', - 'success', - ); - } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update document type.'; - notifyApiError(error, message); - return false; - } - }, - [ - extractDocumentFromResponse, - updateDocumentCaches, - documentTypeLookupById, - notifyApiError, - setStatusMessage, - ], - ); - - const handleDocumentTypeClear = useCallback( - async ({ documentId }, options = {}) => - handleDocumentTypeAssign({ documentId, documentTypeId: null }, options), - [handleDocumentTypeAssign], - ); - - const handleDocumentTypeSet = useCallback( - async ({ document, name, input }) => { - if (!document?.id) { - throw new Error('Missing document for document type assignment.'); - } - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - setStatusMessage('Document type name is required.', 'error'); - return; - } - - let target = documentTypeLookupByName.get(trimmed.toLowerCase()) || null; - if (!target) { - try { - target = await handleDocumentTypeCreate({ name: trimmed }); - } catch (error) { - return; - } - } - - if (!target?.id) { - setStatusMessage('Unable to resolve document type.', 'error'); - return; - } - - const success = await handleDocumentTypeAssign( - { documentId: document.id, documentTypeId: target.id, documentType: target }, - { notify: true }, - ); - - if (success && input) { - input.value = ''; - } - }, - [ - handleDocumentTypeCreate, - handleDocumentTypeAssign, - documentTypeLookupByName, - 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 handleBulkDocumentTypeSet = useCallback( - async ({ name, input, documentIds }) => { - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - setStatusMessage('Document type name is required.', 'error'); - return; - } - - const targets = resolveTargetDocumentIds(documentIds); - if (!targets.length) { - setStatusMessage('Select documents before assigning a document type.', 'error'); - return; - } - - let target = documentTypeLookupByName.get(trimmed.toLowerCase()) || null; - if (!target) { - try { - target = await handleDocumentTypeCreate({ name: trimmed }); - } catch (error) { - return; - } - } - - if (!target?.id) { - setStatusMessage('Unable to resolve document type.', 'error'); - return; - } - - try { - await Promise.all( - targets.map((documentId) => - api.patch(`/documents/${documentId}`, { document_type_id: target.id }), - ), - ); - await refreshCurrentFolder(); - const suffix = targets.length === 1 ? '' : 's'; - setStatusMessage( - `Document type assigned to ${targets.length} document${suffix}.`, - 'success', - ); - if (input) { - input.value = ''; - } - } catch (error) { - const message = error.response?.data?.error || 'Failed to assign document type.'; - notifyApiError(error, message); - } - }, - [ - documentTypeLookupByName, - handleDocumentTypeCreate, - refreshCurrentFolder, - resolveTargetDocumentIds, - setStatusMessage, - notifyApiError, - ], - ); - - const handleBulkDocumentTypeClear = useCallback( - async ({ documentIds }) => { - const targets = resolveTargetDocumentIds(documentIds); - if (!targets.length) { - setStatusMessage('Select documents before clearing the document type.', 'error'); - return; - } - - try { - await Promise.all( - targets.map((documentId) => api.patch(`/documents/${documentId}`, { document_type_id: null })), - ); - await refreshCurrentFolder(); - const suffix = targets.length === 1 ? '' : 's'; - setStatusMessage( - `Document type cleared from ${targets.length} document${suffix}.`, - 'success', - ); - } catch (error) { - const message = error.response?.data?.error || 'Failed to clear document type.'; - notifyApiError(error, message); - } - }, - [resolveTargetDocumentIds, refreshCurrentFolder, setStatusMessage, notifyApiError], - ); - - 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(), refreshDocumentTypes()]); - 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, refreshDocumentTypes, 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); - const folderId = data.folder.id; - cache.set(cacheKey, folderId); - return folderId; - }, - [], - ); - - 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 > maxVisible ? `+${totalCount - maxVisible}` : `${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 ? selectedRowKeys.includes(folderKey) : false; - - let effectiveFolderSelection = selectedFolderIds; - let effectiveDocumentSelection = selectedDocumentIds; - - if (!isAlreadySelected && folderKey) { - effectiveFolderSelection = [folderId]; - effectiveDocumentSelection = []; - handleRowSelection(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'); - }, - [ - selectedRowKeys, - selectedFolderIds, - selectedDocumentIds, - setDraggedFolderId, - setDraggedDocumentIds, - handleRowSelection, - 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 || 'document', - 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 target = targetFolderId === 'root' ? null : targetFolderId; - 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'; - const targetLabel = - target === null - ? DEFAULT_FOLDER_NAME - : folderLabelMap.get(targetFolderId) || 'target folder'; - setStatusMessage( - `Moved ${count} document${suffix} to ${targetLabel}.`, - 'success', - ); - - await refreshCurrentFolder(); - 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); - } - }, - [ - ensureFolderData, - refreshCurrentFolder, - selectedFolder, - folderLabelMap, - notifyApiError, - setStatusMessage, - ], - ); - - 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]; - }); - } - - const targetFolder = doc?.folder_id || selectedFolder || 'root'; - if (targetFolder && targetFolder !== selectedFolder) { - await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive }); - doc = findInCache() || doc; - } - - await ensurePreviewUrl(documentId, { force: false }); - setActivePreviewId(documentId); - return doc; - }, - [ - searchResults, - documents, - assetManager, - setDocuments, - selectedFolder, - loadFolder, - isFilterActive, - ensurePreviewUrl, - setActivePreviewId, - ], - ); - - const openDocumentPreview = useCallback( - (documentId, { replace = false } = {}) => { - if (!documentId) return; - navigate(`/documents/${documentId}`, { replace }); - }, - [navigate], - ); - - const closeDocumentPreview = useCallback( - (folderId = null) => { - const targetId = folderId || selectedFolder || 'root'; - const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; - navigate(path, { replace: false }); - }, - [navigate, selectedFolder], - ); - - const handleDocumentListFocus = useCallback(() => { - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - return; - } - - let resolvedKey = null; - for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) { - const candidate = selectedRowKeys[index]; - if (navigableRowKeys.includes(candidate)) { - resolvedKey = candidate; - break; - } - } - - if (!resolvedKey && navigableRows.length) { - resolvedKey = navigableRows[0].key; - } - - if (!resolvedKey) { - return; - } - - setFocusedRowKey(resolvedKey); - - if (!selectedRowKeys.includes(resolvedKey)) { - applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] }); - } - }, [ - focusedRowKey, - navigableRowKeys, - selectedRowKeys, - navigableRows, - applySelection, - ]); - - const handleDocumentListKeyDown = useCallback( - (event) => { - const { key, shiftKey } = event; - const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; - if (!triggers.includes(key)) { - return; - } - - if (!navigableRows.length) { - return; - } - - event.preventDefault(); - - let activeKey = - focusedRowKey && navigableRowKeys.includes(focusedRowKey) - ? focusedRowKey - : null; - - if (!activeKey) { - for (let index = selectedRowKeys.length - 1; index >= 0; index -= 1) { - const candidate = selectedRowKeys[index]; - if (navigableRowKeys.includes(candidate)) { - activeKey = candidate; - break; - } - } - } - - if (!activeKey) { - activeKey = navigableRowKeys[0]; - setFocusedRowKey(activeKey); - } - - let currentIndex = navigableRowKeys.indexOf(activeKey); - - if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { - const row = currentIndex === -1 ? navigableRows[0] : navigableRows[currentIndex]; - if (!row) { - return; - } - handleRowSelection(row.key, event); - if (row.type === 'folder') { - selectFolder(row.id); - } else if (row.type === 'document') { - openDocumentPreview(row.id); - } - return; - } - - let nextIndex = currentIndex; - - if (key === 'ArrowDown') { - nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); - } else if (key === 'ArrowUp') { - nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); - } else if (key === 'Home') { - nextIndex = 0; - } else if (key === 'End') { - nextIndex = navigableRows.length - 1; - } - - if (nextIndex === -1 || nextIndex >= navigableRows.length) { - return; - } - - if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') { - return; - } - - const targetRow = navigableRows[nextIndex]; - if (!targetRow) { - return; - } - - setFocusedRowKey(targetRow.key); - - handleRowSelection(targetRow.key, { - shiftKey, - preventDefault: () => {}, - }); - }, - [ - navigableRows, - navigableRowKeys, - focusedRowKey, - selectedRowKeys, - handleRowSelection, - selectFolder, - openDocumentPreview, - ], - ); - - 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 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, input) => { - const normalizedLabel = tagManager.normalizeLabel(label); - let 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'); - 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) { - return { id: tagId, label: 'Tag', color: null }; - } - return { - id: source.id ?? tagId, - label: source.label || source.name || 'Tag', - 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; - } - return { ...doc, tags: [...currentTags, resolveTagForCache()] }; - }); - setStatusMessage('Tag assigned.', 'success'); - await refreshCurrentFolder(); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to assign tag.'; - notifyApiError(error, message); - return false; - } - }, - [ - refreshCurrentFolder, - notifyApiError, - setStatusMessage, - updateDocumentCaches, - tagLookupById, - ], - ); - - 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 handleFolderDelete = useCallback( - async (folderId) => { - if (!token) { - setStatusMessage('Log in to manage folders.', 'error'); - return; - } - if (folderId === 'root') { - setStatusMessage('The root folder cannot be removed.', 'error'); - return; - } - 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) { - setStatusMessage('Folder must be empty before it can be deleted.', 'error'); - return; - } - 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 }); - } - setStatusMessage('Folder deleted.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete folder.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [ - token, - ensureFolderData, - selectedFolder, - folderNodes, - applySelectedFolder, - notifyApiError, - setStatusMessage, - ], - ); - - 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 openTagsModal = useCallback(() => { - setCorrespondentsModalOpen(false); - setDocumentTypesModalOpen(false); - setTagsModalOpen(true); - }, []); - - const closeTagsModal = useCallback(() => { - setTagsModalOpen(false); - }, []); - - const openCorrespondentsModal = useCallback(() => { - setTagsModalOpen(false); - setCorrespondentsModalOpen(true); - setDocumentTypesModalOpen(false); - }, []); - - const closeCorrespondentsModal = useCallback(() => { - setCorrespondentsModalOpen(false); - }, []); - - const openDocumentTypesModal = useCallback(() => { - setTagsModalOpen(false); - setCorrespondentsModalOpen(false); - setDocumentTypesModalOpen(true); - }, []); - - const closeDocumentTypesModal = useCallback(() => { - setDocumentTypesModalOpen(false); - }, []); - - const openSettingsModal = useCallback(() => { - setSettingsModalOpen(true); - }, []); - - const closeSettingsModal = useCallback(() => { - setSettingsModalOpen(false); - }, []); - - useEffect(() => { - setTagsModalOpen(false); - setCorrespondentsModalOpen(false); - setDocumentTypesModalOpen(false); - setSettingsModalOpen(false); - }, [location.pathname]); - - useEffect(() => { - if (!isTagsModalOpen && !isCorrespondentsModalOpen && !isDocumentTypesModalOpen && !isSettingsModalOpen) { - return; - } - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - event.preventDefault(); - if (isTagsModalOpen) { - closeTagsModal(); - } else if (isCorrespondentsModalOpen) { - closeCorrespondentsModal(); - } else if (isDocumentTypesModalOpen) { - closeDocumentTypesModal(); - } else if (isSettingsModalOpen) { - closeSettingsModal(); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [ - isTagsModalOpen, - isCorrespondentsModalOpen, - isDocumentTypesModalOpen, - isSettingsModalOpen, - closeTagsModal, - closeCorrespondentsModal, - closeDocumentTypesModal, - closeSettingsModal, - ]); - - 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) { - params.tags = activeTagFilters.join(','); - } - if (activeCorrespondentFilters.length) { - params.correspondents = activeCorrespondentFilters.join(','); - } - if (activeDocumentTypeFilters.length) { - params.document_types = activeDocumentTypeFilters.join(','); - } - const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; - if (folderIdentifier) { - params.folder_id = folderIdentifier; - } - const { data } = await api.get('/documents', { params }); - if (cancelled) return; - - const results = assetManager.hydrateDocuments(data || []); - setSearchResults(results); - - if (!results.length) { - setSearchLoading(false); - setSelectedRowKeys([]); - setFocusedDocumentId(null); - selectionOrderRef.current = []; - setSelectionOrder([]); - selectionAnchorRef.current = null; - return; - } - - const resultKeys = results - .map((doc) => resolveDocumentRowKey(doc.id)) - .filter(Boolean); - - let targetKey = null; - let nextSelectionKeys = []; - - setSelectedRowKeys((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 = resultKeys[0] || null; - nextSelectionKeys = targetKey ? [targetKey] : []; - return nextSelectionKeys; - }); - - selectionOrderRef.current = nextSelectionKeys; - setSelectionOrder(nextSelectionKeys); - - const targetDocId = targetKey ? getRowId(targetKey) : null; - - setFocusedDocumentId((previous) => { - if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { - return previous; - } - return targetDocId; - }); - - 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, - activeDocumentTypeFilters, - selectedFolder, - notifyApiError, - assetManager, - ]); - - 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) => { - const types = event?.dataTransfer?.types; - if (!types) { - return false; - } - if (typeof types.includes === 'function') { - return TAG_MIME_TYPES.some((type) => types.includes(type)); - } - return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type)); - }; - - 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 handleDocumentDelete = useCallback( - async (documentId) => { - if (!documentId) return; - if (!token) { - setStatusMessage('Log in to manage documents.', 'error'); - return; - } - - const doc = documentLookup.get(documentId) || null; - const label = doc?.title; - - const confirmed = window.confirm(`Move "${label}" to trash? You can restore it from trash later.`); - if (!confirmed) { - return; - } - - setLoading(true); - try { - await api.delete(`/documents/${documentId}`); - - removeDocumentFromCaches(documentId); - - setPreviewEntries((prev) => { - if (!prev.has(documentId)) { - return prev; - } - const next = new Map(prev); - next.delete(documentId); - return next; - }); - previewInflightRef.current.delete(documentId); - - if (selectedDocumentIds.includes(documentId)) { - const remainingRowKeys = selectedDocumentIds - .filter((id) => id !== documentId) - .map((id) => resolveDocumentRowKey(id)) - .filter(Boolean); - const removedKey = resolveDocumentRowKey(documentId); - applySelection(remainingRowKeys, { - anchor: null, - interactedKeys: removedKey ? [removedKey] : [], - }); - } - - if (previewDocumentId === documentId) { - closeDocumentPreview(); - } - - setStatusMessage('Document deleted.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete document.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [ - token, - documentLookup, - setStatusMessage, - removeDocumentFromCaches, - setPreviewEntries, - previewInflightRef, - previewDocumentId, - closeDocumentPreview, - selectedDocumentIds, - applySelection, - notifyApiError, - ], - ); - - 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]); - - useEffect(() => { - if (!orderedSelectedDocuments.length) { - return; - } - - const nextIds = orderedSelectedDocuments - .map((doc) => (doc?.id ? doc.id : null)) - .filter(Boolean); - - setDetailPanelDocIds((prev) => { - if (nextIds.length === prev.length && nextIds.every((id, index) => id === prev[index])) { - return prev; - } - return nextIds; - }); - }, [orderedSelectedDocuments]); - - useEffect(() => { - if (!detailPanelDocIds.length) { - setDetailPanelDocs([]); - return; - } - - setDetailPanelDocs((prevDocs) => { - const prevMap = new Map((prevDocs || []).map((doc) => [doc.id, doc])); - const next = detailPanelDocIds - .map((id) => documentLookup.get(id) || prevMap.get(id) || null) - .filter(Boolean); - - if (next.length === prevDocs.length && next.every((doc, index) => doc === prevDocs[index])) { - return prevDocs; - } - return next; - }); - }, [detailPanelDocIds, documentLookup]); - - const detailPanelSelectedDocuments = detailPanelDocs; - - const lastScrolledDetailDocRef = useRef(null); - - useEffect(() => { - if (!detailPanelOpen) { - lastScrolledDetailDocRef.current = null; - return; - } - - if (!orderedSelectedDocuments.length) { - lastScrolledDetailDocRef.current = null; - return; - } - - let lastSelectedId = null; - for (let index = selectionOrder.length - 1; index >= 0; index -= 1) { - const key = selectionOrder[index]; - if (isDocumentRowKey(key)) { - lastSelectedId = getRowId(key); - if (lastSelectedId) { - break; - } - } - } - - if (!lastSelectedId && orderedSelectedDocuments.length) { - const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; - lastSelectedId = fallbackDoc?.id || null; - } - - if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) { - return; - } - - if (typeof document === 'undefined') { - return; - } - - const targetElement = - document.getElementById(`document-row-${lastSelectedId}`) || - document.getElementById(`document-card-${lastSelectedId}`); - - if (!targetElement) { - return; - } - - lastScrolledDetailDocRef.current = lastSelectedId; - requestAnimationFrame(() => { - targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }); - }, [orderedSelectedDocuments, selectionOrder, documentsViewMode, detailPanelOpen]); - - 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 showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]); - const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [setWorkspaceMode]); - const handleDocumentsViewModeChange = useCallback((mode) => { - setDocumentsViewMode((previous) => { - const next = mode === 'grid' ? 'grid' : 'list'; - if (next !== previous && typeof window !== 'undefined') { - window.localStorage.setItem('papercrate_view_mode', next); - } - return next; - }); - }, []); - - 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 }); - } - - setWorkspaceMode('table'); - navigate('/documents', { replace: true }); - - await Promise.all([refreshTags(), refreshCorrespondents(), refreshDocumentTypes()]); - 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, - setWorkspaceMode, - navigate, - refreshTags, - refreshCorrespondents, - refreshDocumentTypes, - loadFolder, - ], - ); - - const documentsTableProps = useMemo( - () => ({ - currentFolderName, - breadcrumbs, - onRefresh: refreshCurrentFolder, - onShowSkeuoWorkspace: showSkeuoWorkspace, - subfolders: currentSubfolders, - documents, - searchResults, - isFilterActive, - onFolderSelect: selectFolder, - onFolderDrop: folderClickHandlers.onDrop, - onFolderDragOver: folderClickHandlers.onDragOver, - onFolderDragLeave: folderClickHandlers.onDragLeave, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onFolderDelete: handleFolderDelete, - onFolderRowClick: handleFolderRowClick, - onFolderRename: handleFolderRename, - onDocumentRowClick: handleDocumentRowClick, - onDocumentOpen: openDocumentPreview, - onDocumentDelete: handleDocumentDelete, - onDocumentRename: handleDocumentTitleUpdate, - selectedDocumentIds, - selectedFolderIds, - focusedDocumentId, - focusedRowKey, - draggingDocumentIds: draggedDocumentIds, - onDocumentDragStart: handleDocumentDragStart, - onDocumentDragEnd: handleDocumentDragEnd, - isSearchLoading: searchLoading, - tagLookupById, - activeCorrespondentIds: activeCorrespondentFilters, - onDocumentListFocus: handleDocumentListFocus, - onDocumentListKeyDown: handleDocumentListKeyDown, - onFocusedRowChange: setFocusedRowKey, - ensureAssetUrl, - getDocumentAsset, - getDownloadHref: (doc) => - doc?.current_version?.download_path - ? resolveApiPath(doc.current_version.download_path) - : null, - onTagClick: toggleTagFilter, - onCorrespondentClick: toggleCorrespondentFilter, - onDocumentTagDrop: handleDocumentTagDrop, - viewMode: documentsViewMode, - onViewModeChange: handleDocumentsViewModeChange, - onClearSelection: clearDocumentSelection, - }), - [ - activeCorrespondentFilters, - breadcrumbs, - clearDocumentSelection, - currentFolderName, - currentSubfolders, - documents, - documentsViewMode, - draggedDocumentIds, - draggedFolderId, - focusedDocumentId, - focusedRowKey, - folderClickHandlers, - handleDocumentDelete, - handleDocumentDragEnd, - handleDocumentDragStart, - handleDocumentListFocus, - handleDocumentListKeyDown, - handleDocumentRowClick, - handleDocumentTagDrop, - handleDocumentTitleUpdate, - handleDocumentsViewModeChange, - handleFolderDelete, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderRename, - handleFolderRowClick, - isFilterActive, - openDocumentPreview, - refreshCurrentFolder, - searchLoading, - searchResults, - selectFolder, - selectedDocumentIds, - selectedFolderIds, - setFocusedRowKey, - showSkeuoWorkspace, - tagLookupById, - toggleCorrespondentFilter, - toggleTagFilter, - ensureAssetUrl, - getDocumentAsset, - ], - ); - - 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, - onNeutralHueChange: handleNeutralHueChange, - neutralHue, - tags, - activeTagIds: activeTagFilters, - onToggleTagFilter: toggleTagFilter, - onCreateTag: (label) => handleTagCreate({ label }), - correspondents, - activeCorrespondentIds: activeCorrespondentFilters, - onToggleCorrespondentFilter: toggleCorrespondentFilter, - onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }), - documentTypes, - activeDocumentTypeIds: activeDocumentTypeFilters, - onToggleDocumentTypeFilter: toggleDocumentTypeFilter, - onCreateDocumentType: (name) => handleDocumentTypeCreate({ name }), - appStatus, - loading, - previewActive, - searchQuery, - onSearchChange: handleSearchChange, - onSearchSubmit: handleSearchSubmit, - onSearchClear: clearFilters, - isFilterActive, - onLogout: handleLogout, - status, - tenantName, - tenants: tenantOptions, - activeTenantId: currentTenantId, - onSelectTenant: handleTenantSelect, - onOpenSettings: openSettingsModal, - }), - [ - activeCorrespondentFilters, - activeTagFilters, - appStatus, - clearFilters, - correspondents, - currentTenantId, - documentTypes, - folderClickHandlers, - folderNodes, - handleFolderDelete, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderRename, - handleLogout, - handleSearchChange, - handleSearchSubmit, - handleTenantSelect, - loading, - openSettingsModal, - previewActive, - searchQuery, - draggedFolderId, - isFilterActive, - selectedFolder, - status, - tags, - tenantOptions, - tenantName, - toggleCorrespondentFilter, - toggleTagFilter, - handleTagCreate, - handleCorrespondentCreate, - toggleDocumentTypeFilter, - handleDocumentTypeCreate, - handlePromptCreateFolder, - creatingFolder, - handleNeutralHueChange, - neutralHue, - activeDocumentTypeFilters, - ], - ); - - const handleDetailPanelClose = useCallback(() => { - setDetailPanelOpen(false); - clearDocumentSelection(); - setDetailPanelDocIds([]); - setDetailPanelDocs([]); - }, [clearDocumentSelection]); - - const detailPanelProps = useMemo( - () => ({ - selectedDocuments: detailPanelSelectedDocuments, - tags, - tagLookupById, - onTagAdd: handleTagAdd, - onTagRemove: handleTagRemove, - onRegenerateThumbnails: handleThumbnailRegeneration, - previewEntry: selectedPreviewEntry, - onOpenPreview: openDocumentPreview, - onBulkTagAdd: handleBulkTagAddFromDetail, - onBulkTagRemove: handleBulkTagRemoveFromDetail, - onBulkReanalyze: handleBulkSelectionReanalyze, - onBulkCorrespondentAdd: handleBulkCorrespondentAdd, - onBulkCorrespondentRemove: handleBulkCorrespondentRemove, - onPromoteSelection: promoteSelectionOrder, - activePreviewId, - onUpdateTitle: handleDocumentTitleUpdate, - ensureAssetUrl, - getDocumentAsset, - ensurePreviewData, - correspondents, - onCorrespondentAdd: handleCorrespondentAdd, - onCorrespondentRemove: handleCorrespondentRemove, - documentTypes, - onDocumentTypeSet: handleDocumentTypeSet, - onDocumentTypeClear: handleDocumentTypeClear, - onBulkDocumentTypeSet: handleBulkDocumentTypeSet, - onBulkDocumentTypeClear: handleBulkDocumentTypeClear, - resolveApiPath, - onFolderNavigate: selectFolder, - onClose: handleDetailPanelClose, - resolveFolderPath, - }), - [ - activePreviewId, - correspondents, - detailPanelSelectedDocuments, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - handleBulkCorrespondentAdd, - handleBulkCorrespondentRemove, - handleBulkDocumentTypeSet, - handleBulkDocumentTypeClear, - handleBulkSelectionReanalyze, - handleBulkTagAddFromDetail, - handleBulkTagRemoveFromDetail, - handleCorrespondentAdd, - handleCorrespondentRemove, - handleDocumentTypeSet, - handleDocumentTypeClear, - handleDetailPanelClose, - handleDocumentTitleUpdate, - handleTagAdd, - handleTagRemove, - handleThumbnailRegeneration, - openDocumentPreview, - promoteSelectionOrder, - resolveFolderPath, - selectFolder, - selectedPreviewEntry, - tags, - tagLookupById, - documentTypes, - ], - ); - - const skeuoWorkspaceProps = useMemo( - () => ({ - documents, - searchResults, - breadcrumbs, - currentFolderName, - onExit: exitSkeuoWorkspace, - onRefresh: refreshCurrentFolder, - onDocumentOpen: openDocumentPreview, - resolveThumbnailUrl: resolveThumbnailUrlForDoc, - onAssignTagToDocument: handleDocumentTagAttach, - onRemoveTagFromDocument: handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagIds: activeTagFilters, - }), - [ - documents, - searchResults, - breadcrumbs, - currentFolderName, - exitSkeuoWorkspace, - refreshCurrentFolder, - openDocumentPreview, - resolveThumbnailUrlForDoc, - handleDocumentTagAttach, - handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - ], - ); - - const contextValue = useMemo( - () => ({ - token, - appStatus, - status, - setStatusMessage, - dropOverlayState, - handleLogout, - sidebarProps, - tags, - refreshTags, - handleTagUpdate, - handleTagDelete, - handleDocumentTagAttach, - correspondents, - refreshCorrespondents, - handleCorrespondentUpdate, - handleCorrespondentCreate, - handleCorrespondentDelete, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - documentTypes, - refreshDocumentTypes, - handleDocumentTypeUpdate, - handleDocumentTypeCreate, - handleDocumentTypeDelete, - handleDocumentTypeAssign, - handleDocumentTypeClear, - handleDocumentTypeSet, - handleBulkDocumentTypeSet, - handleBulkDocumentTypeClear, - previewActive, - previewWorkspaceDocument, - previewWorkspaceEntry, - closeDocumentPreview, - handleThumbnailRegeneration, - documentsTableProps, - detailPanelProps, - workspaceMode, - showSkeuoWorkspace, - exitSkeuoWorkspace, - skeuoWorkspaceProps, - ensurePreviewData, - ensureAssetUrl, - resolveApiPath, - getDocumentAsset, - notifyApiError, - openTagsModal, - openCorrespondentsModal, - openDocumentTypesModal, - openSettingsModal, - detailPanelOpen, - setDetailPanelOpen, - }), - [ - token, - appStatus, - status, - setStatusMessage, - dropOverlayState, - handleLogout, - sidebarProps, - tags, - refreshTags, - handleTagUpdate, - handleTagDelete, - handleDocumentTagAttach, - correspondents, - refreshCorrespondents, - handleCorrespondentUpdate, - handleCorrespondentCreate, - handleCorrespondentDelete, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - documentTypes, - refreshDocumentTypes, - handleDocumentTypeUpdate, - handleDocumentTypeCreate, - handleDocumentTypeDelete, - handleDocumentTypeAssign, - handleDocumentTypeClear, - handleDocumentTypeSet, - handleBulkDocumentTypeSet, - handleBulkDocumentTypeClear, - previewActive, - previewWorkspaceDocument, - previewWorkspaceEntry, - closeDocumentPreview, - handleThumbnailRegeneration, - documentsTableProps, - detailPanelProps, - workspaceMode, - showSkeuoWorkspace, - exitSkeuoWorkspace, - skeuoWorkspaceProps, - ensurePreviewData, - ensureAssetUrl, - getDocumentAsset, - notifyApiError, - openTagsModal, - openCorrespondentsModal, - openDocumentTypesModal, - openSettingsModal, - detailPanelOpen, - setDetailPanelOpen, - ], - ); - - if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { - const shouldRememberLastLocation = appStatus !== 'logged-out'; - return ( - - ); - } - - return ( - -
- - - {isTagsModalOpen && ( -
-
event.stopPropagation()} - > -
-

Manage Tags

- -
-
- -
-
-
- )} - {isCorrespondentsModalOpen && ( -
-
event.stopPropagation()} - > -
-

Manage Correspondents

- -
-
- -
-
-
-)} - {isDocumentTypesModalOpen && ( -
-
event.stopPropagation()} - > -
-

Manage Document Types

- -
-
- -
-
-
- )} - -
-
- ); -}; - -const DocumentsRoute = () => { - const { - sidebarProps, - documentsTableProps, - detailPanelProps, - detailPanelOpen, - workspaceMode, - skeuoWorkspaceProps, - openTagsModal, - openCorrespondentsModal, - openDocumentTypesModal, - previewWorkspaceDocument, - previewWorkspaceEntry, - closeDocumentPreview, - handleThumbnailRegeneration, - ensurePreviewData, - resolveApiPath, - ensureAssetUrl, - getDocumentAsset, - notifyApiError, - } = useAppShell(); - const navigate = useNavigate(); - - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []); - const expandSidebar = useCallback(() => setSidebarCollapsed(false), []); - - const sidebarPropsWithActions = useMemo( - () => ({ - ...sidebarProps, - onManageTags: openTagsModal, - onManageCorrespondents: openCorrespondentsModal, - onManageDocumentTypes: openDocumentTypesModal, - onCollapse: collapseSidebar, - }), - [sidebarProps, openTagsModal, openCorrespondentsModal, openDocumentTypesModal, collapseSidebar], - ); - - const breadcrumbs = documentsTableProps?.breadcrumbs || null; - const parentBreadcrumb = useMemo(() => { - if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) { - return null; - } - return breadcrumbs[breadcrumbs.length - 2]; - }, [breadcrumbs]); - - const handleNavigateParent = useCallback(() => { - if (!parentBreadcrumb) { - return; - } - const target = parentBreadcrumb.id === 'root' - ? '/documents' - : `/documents/folder/${parentBreadcrumb.id}`; - navigate(target); - }, [navigate, parentBreadcrumb]); - - const isWorkspace = workspaceMode === 'skeuo'; - const isPreviewWorkspace = Boolean(previewWorkspaceDocument); - const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace; - - const renderSidebarToggle = useCallback(() => { - if (!sidebarCollapsed) { - return null; - } - return ( - - ); - }, [sidebarCollapsed, expandSidebar]); - - const documentsSurface = useMemo(() => { - if (!documentsTableProps) { - return null; - } - return createDocumentsSurface({ - tableProps: documentsTableProps, - parentBreadcrumb, - onNavigateParent: parentBreadcrumb ? handleNavigateParent : null, - renderSidebarToggle, - detailProps: detailPanelProps, - detailOpen: detailPanelOpen, - }); - }, [ - documentsTableProps, - parentBreadcrumb, - handleNavigateParent, - renderSidebarToggle, - detailPanelProps, - detailPanelOpen, - ]); - - const previewSurface = useMemo(() => { - if (!showPreviewWorkspace || !previewWorkspaceDocument) { - return null; - } - return createPreviewSurface({ - document: previewWorkspaceDocument, - previewEntry: previewWorkspaceEntry, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, - onRegenerate: handleThumbnailRegeneration, - onClose: closeDocumentPreview, - renderSidebarToggle, - }); - }, [ - showPreviewWorkspace, - previewWorkspaceDocument, - previewWorkspaceEntry, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, - handleThumbnailRegeneration, - closeDocumentPreview, - renderSidebarToggle, - ]); - - const workspaceSurface = useMemo(() => { - if (!isWorkspace) { - return null; - } - return createDesktopSurface({ - workspaceProps: skeuoWorkspaceProps, - renderSidebarToggle, - }); - }, [isWorkspace, skeuoWorkspaceProps, renderSidebarToggle]); - - const surface = showPreviewWorkspace - ? previewSurface - : isWorkspace - ? workspaceSurface - : documentsSurface; - - if (!surface) { - return ( - -
-
-
- - ); - } - - const variant = surface.variant || 'documents'; - - const mainContentClass = `main-content main-content--${variant}${ - surface.detail ? ' main-content--has-detail' : '' - }`; - const bodyClass = `main-content__body main-content__body--${variant}${ - surface.detail ? ' main-content__body--has-detail' : '' - }`; - - const header = surface.header || null; - - return ( - -
- {header ? ( -
- {header.leading} -

- {header.title} - {header.subtitle ? ( - {header.subtitle} - ) : null} -

-
- {header.actions} -
- ) : null} -
{surface.content}
- {surface.detail || null} -
- - ); -}; - -const LoginRoute = () => { - const { status: appStatus, tenantSelection } = useAppState(); - const appDispatch = useAppDispatch(); - const location = useLocation(); - const [status, setStatus] = useState(null); - const [selectingTenantId, setSelectingTenantId] = useState(null); - const passkeySupported = isWebAuthnAvailable(); - const [passkeyLoading, setPasskeyLoading] = useState(false); - const signupSupported = passkeySupported; - const [signupLoading, setSignupLoading] = useState(false); - - const setStatusMessage = useCallback((message, variant = 'info') => { - setStatus(message ? { message, variant } : null); - }, []); - const handleLoginApiReport = useCallback( - ({ message, variant }) => setStatusMessage(message, variant), - [setStatusMessage], - ); - const reportLoginError = useApiError({ - onReport: handleLoginApiReport, - }); - const notifyLoginError = useCallback( - (error, fallbackMessage, variant = 'error') => - reportLoginError(error, { message: fallbackMessage, variant }), - [reportLoginError], - ); - - const handleTenantSelect = useCallback( - async (tenant) => { - if (!tenantSelection?.selectionToken || !tenant?.id) { - return; - } - - try { - setSelectingTenantId(tenant.id); - const { data } = await api.post( - '/auth/select-tenant', - { tenant_id: tenant.id }, - { - headers: { - Authorization: `Bearer ${tenantSelection.selectionToken}`, - }, - }, - ); - - if (!data?.access_token) { - throw new Error('Invalid tenant selection response.'); - } - - appDispatch({ - type: 'LOGIN_SUCCESS', - token: data.access_token, - tenant: data.tenant || null, - }); - setStatusMessage('Login successful.', 'success'); - } catch (error) { - const message = error?.response?.data?.error || 'Failed to finalize login.'; - notifyLoginError(error, message, 'error'); - } finally { - setSelectingTenantId(null); - } - }, - [appDispatch, notifyLoginError, setStatusMessage, tenantSelection], - ); - - const handleCancelSelection = useCallback(() => { - appDispatch({ type: 'CLEAR_TENANT_SELECTION' }); - setStatusMessage(null); - }, [appDispatch, setStatusMessage]); - - const handlePasskeyLogin = useCallback( - async (rawUsername) => { - const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; - if (!username) { - setStatusMessage('Enter your username before using a passkey.', 'error'); - return; - } - if (!passkeySupported) { - setStatusMessage('Passkeys are not supported in this browser.', 'error'); - return; - } - - setPasskeyLoading(true); - appDispatch({ type: 'LOGIN_REQUEST' }); - try { - const { data: startData } = await api.post('/auth/passkeys/login/start', { username }); - const challengeId = startData.challengeId; - const publicKeyOptions = startData.publicKey; - - if (!challengeId || !publicKeyOptions) { - throw new Error('Invalid passkey challenge response.'); - } - - const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions }); - setStatusMessage('Confirm the passkey prompt to continue.', 'info'); - const assertion = await navigator.credentials.get({ publicKey }); - - if (!assertion) { - setStatusMessage('Passkey login cancelled.', 'info'); - return; - } - - const serialized = serializeAuthenticationCredential(assertion); - const finishPayload = { - challengeId, - credential: serialized, - }; - - const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload); - - if (finishData?.access_token && Array.isArray(finishData?.tenants)) { - appDispatch({ - type: 'TENANT_SELECTION_REQUIRED', - selectionToken: finishData.access_token, - tenants: finishData.tenants, - }); - setStatusMessage('Select a tenant to continue.', 'info'); - return; - } - - if (!finishData?.access_token) { - throw new Error('Invalid login response.'); - } - - appDispatch({ - type: 'LOGIN_SUCCESS', - token: finishData.access_token, - tenant: finishData.tenant || null, - }); - setStatusMessage('Login successful.', 'success'); - } catch (error) { - if (error?.name === 'NotAllowedError') { - setStatusMessage('Passkey login cancelled.', 'info'); - } else if (error?.response?.status === 404) { - setStatusMessage('No passkey registered for this username. Create an account first.', 'error'); - appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' }); - } else if (error?.response?.status === 400 && error?.response?.data?.error) { - setStatusMessage(error.response.data.error, 'error'); - appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error }); - } else { - const message = error?.response?.data?.error || error.message || 'Passkey login failed.'; - notifyLoginError(error, message); - appDispatch({ type: 'LOGIN_FAILURE', error: message }); - } - } finally { - setPasskeyLoading(false); - } - }, - [appDispatch, notifyLoginError, passkeySupported, setStatusMessage], - ); - - const handleSignup = useCallback( - async (rawUsername) => { - const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; - if (!username) { - setStatusMessage('Choose a username to create your account.', 'error'); - return; - } - if (!passkeySupported) { - setStatusMessage('Passkeys are not supported in this browser.', 'error'); - return; - } - - setSignupLoading(true); - try { - const { data: startData } = await api.post('/auth/signup/start', { username }); - const signupToken = startData.signup_token; - const challengePayload = startData.challenge; - const challengeId = challengePayload?.challengeId; - const publicKeyOptions = challengePayload?.publicKey; - - if (!signupToken || !challengeId || !publicKeyOptions) { - throw new Error('Invalid signup challenge response.'); - } - - const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); - setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info'); - const credential = await navigator.credentials.create({ publicKey }); - - if (!credential) { - setStatusMessage('Signup cancelled.', 'info'); - return; - } - - const serialized = serializeRegistrationCredential(credential); - const finishPayload = { - signup_token: signupToken, - credential: serialized, - }; - - const { data: finishData } = await api.post('/auth/signup/finish', finishPayload); - - if (finishData?.access_token && Array.isArray(finishData?.tenants)) { - appDispatch({ - type: 'TENANT_SELECTION_REQUIRED', - selectionToken: finishData.access_token, - tenants: finishData.tenants, - }); - setStatusMessage('Select a tenant to continue.', 'info'); - return; - } - - if (!finishData?.access_token) { - throw new Error('Invalid signup response.'); - } - - appDispatch({ - type: 'LOGIN_SUCCESS', - token: finishData.access_token, - tenant: finishData.tenant || null, - }); - setStatusMessage('Account created. Welcome!', 'success'); - } catch (error) { - if (error?.name === 'NotAllowedError') { - setStatusMessage('Signup cancelled.', 'info'); - } else if (error?.response?.status === 409) { - setStatusMessage('This passkey is already registered. Try signing in instead.', 'error'); - } else if (error?.response?.data?.error) { - setStatusMessage(error.response.data.error, 'error'); - } else { - const message = error?.message || 'Failed to create account.'; - setStatusMessage(message, 'error'); - } - } finally { - setSignupLoading(false); - } - }, - [appDispatch, passkeySupported, setStatusMessage], - ); - - const redirectTarget = useMemo(() => { - const target = location.state?.from; - if (typeof target === 'string' && target.startsWith('/')) { - return target; - } - return '/documents'; - }, [location.state]); - - if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { - return ; - } - - return ( -
- -
- ); -}; - -const AppRouter = () => ( - - } /> - }> - } /> - } /> - } /> - } /> - } /> - - -); +import { AppStateProvider } from './app/appState'; +import AppRouter from './app/AppRouter'; const container = document.getElementById('app'); const root = createRoot(container); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9e33b5f..2aa4e88 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1390,7 +1390,7 @@ button.danger:hover:not([disabled]) { gap: 0.4rem; border: none; background: none; - color: var(--danger, #d14343); + color: var(--danger); font: inherit; cursor: pointer; padding: 0.4rem 0.5rem; @@ -2786,7 +2786,7 @@ form.inline { } .settings-form__error { - color: var(--danger, #d14343); + color: var(--danger); font-size: 0.85rem; margin: 0 0 0.75rem; }