diff --git a/frontend/src/app/AppLayout.tsx b/frontend/src/app/AppLayout.tsx deleted file mode 100644 index c56721c..0000000 --- a/frontend/src/app/AppLayout.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; -import { AppShellContext } from '../appShellContext'; -import DropOverlay from './DropOverlay'; -import UploadQueueOverlay from './UploadQueueOverlay'; -import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; -import { useDocumentsPreferences } from './useDocumentsPreferences'; -import SettingsRoute from './SettingsRoute'; - -const AppLayout: React.FC = () => { - const documentsPreferences = useDocumentsPreferences(); - const { - appStatus, - location, - shellRef, - dropOverlayState, - managementModals, - contextValue, - settingsOpen, - closeSettings, - } = useDocumentsWorkspace({ - documentsViewMode: documentsPreferences.documentsViewMode, - documentsSortField: documentsPreferences.documentsSortField, - documentsSortDirection: documentsPreferences.documentsSortDirection, - documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, - documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, - onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, - onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, - onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, - searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, - onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, - sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, - }); - - if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { - const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`; - return ( - - ); - } - - return ( - -
- - - - {managementModals} - {settingsOpen ? ( - - ) : null} -
-
- ); -}; - -export default AppLayout; diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx deleted file mode 100644 index fb5488b..0000000 --- a/frontend/src/app/AppRouter.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index 73faf7d..49b74b2 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -9,7 +9,7 @@ import React, { } from 'react'; import { resolveDocumentAssetUrl } from '../asset_manager'; import type { EnsureAssetUrl, GetAsset } from '../asset_manager'; -import { formatTransform } from './math'; +import { formatTransform } from '../utils/math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import { @@ -128,6 +128,8 @@ interface DesktopWorkspaceProps { activeTagFilters?: Array; tenantId?: Identifier | null; viewId?: string | null; + documentLinks?: Map | null; + ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise; } interface DesktopWorkspaceViewProps { @@ -817,6 +819,8 @@ const DesktopWorkspace: React.FC = ({ overlayOriginTransform, overlayDocument, onDocumentClick, + handleStackSelect, + handlePromoteSelection, onDocumentStackSelect: handleStackSelect, onPromoteSelection: handlePromoteSelection, selectedDocumentIds, @@ -862,6 +866,8 @@ const DesktopWorkspace: React.FC = ({ layoutRef, layoutSnapshot, onDocumentClick, + handleStackSelect, + handlePromoteSelection, openOverlayForDoc, overlayDisplay, overlayOriginRect, @@ -874,6 +880,7 @@ const DesktopWorkspace: React.FC = ({ resolveBaseMetrics, setDraggingId, selectedDocumentIds, + clearSelection, onDocumentActivate, markLayoutDirty, tagDropTargetId, diff --git a/frontend/src/desktop/math.ts b/frontend/src/desktop/math.ts deleted file mode 100644 index fcf93d7..0000000 --- a/frontend/src/desktop/math.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { clamp } from '../utils/math'; - -export const formatTransform = ( - x: number, - y: number, - rotation = 0, - scale = 1, -): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index 7c6c9ca..a237e40 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -7,7 +7,7 @@ import { } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { preventAll, safeInvoke } from './events'; -import { clamp } from './math'; +import { clamp } from '../utils/math'; import usePointerTap from '../ui/usePointerTap'; import { MIN_TIMESTEP, diff --git a/frontend/src/desktop/workspaceEngine.ts b/frontend/src/desktop/workspaceEngine.ts index 0bf2ad5..4cf8e70 100644 --- a/frontend/src/desktop/workspaceEngine.ts +++ b/frontend/src/desktop/workspaceEngine.ts @@ -1,4 +1,4 @@ -import { clamp, formatTransform } from './math'; +import { clamp, formatTransform } from '../utils/math'; import { fetchLayoutRecords, upsertLayoutRecords } from './db'; type DocumentId = string; diff --git a/frontend/src/hooks/documents/useDocumentDragHandlers.ts b/frontend/src/hooks/documents/useDocumentDragHandlers.ts index 3ff67f2..12c0591 100644 --- a/frontend/src/hooks/documents/useDocumentDragHandlers.ts +++ b/frontend/src/hooks/documents/useDocumentDragHandlers.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react'; import type { DragEvent } from 'react'; -import { isPlainObject, isFunctionValue } from '../../utils/typeGuards'; +import { isPlainObject } from '../../utils/typeGuards'; type Identifier = string | number; type FolderIdentifier = Identifier | 'root'; @@ -150,7 +150,7 @@ const useDocumentDragHandlers = ({ return (payload as { id?: FolderIdentifier }).id ?? null; } const maybeTrim = (payload as { trim?: () => string })?.trim; - if (isFunctionValue(maybeTrim)) { + if (typeof maybeTrim === 'function') { const nextValue = maybeTrim.call(payload); return nextValue || null; } diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 0f2b1b4..df9582f 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -16,7 +16,6 @@ import { import AssetManager, { getAssetFromVersion } from '../../asset_manager'; import useApiError from '../useApiError'; import TagManager from '../../tag_manager'; -import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; import { useAppDispatch, useAppState } from '../../app/appState'; import { fetchAsset } from '../../lib/apiClient'; @@ -31,7 +30,6 @@ import useDocumentPreview from '../../app/useDocumentPreview'; import useSidebarProps from '../../sidebar/useSidebarProps'; import { ASSET_PRESIGN_TTL_MS, - DEFAULT_FOLDER_NAME, DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD, createRootNode, @@ -46,19 +44,20 @@ import { import useDocumentsSearch from '../../app/useDocumentsSearch'; import useDocumentsStore from './store/useDocumentsStore'; import useAuthManager from './useAuthManager'; -import useTags from './useTags'; -import useCorrespondents from './useCorrespondents'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; import { fetchDocument } from '../../lib/apiClient'; import useFolderTree from './useFolderTree'; import useFolderTreeActions from './useFolderTreeActions'; import useDocumentTagging from './useDocumentTagging'; -import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; import useDocumentUploads from './useDocumentUploads'; import useDocumentDragHandlers from './useDocumentDragHandlers'; import useDocumentMutations from './useDocumentMutations'; import useDetailWorkspace from '../../detail/useDetailWorkspace'; +import useWorkspaceTaxonomies from './useWorkspaceTaxonomies'; +import useWorkspaceBreadcrumbs from './useWorkspaceBreadcrumbs'; +import useWorkspaceDeskProps from './useWorkspaceDeskProps'; +import useWorkspaceSelectionSync from './useWorkspaceSelectionSync'; const EntryType = Object.freeze({ document: 'document', @@ -479,17 +478,7 @@ const useDocumentsWorkspace = ({ const bootstrapInitializedRef = useRef(false); const detailFolderFetchRef = useRef(new Set()); - - useEffect(() => { - if (!showingSearchResults) { - return; - } - setSelectedEntries([]); - setSelectionOrder([]); - selectionOrderRef.current = []; - selectionAnchorRef.current = null; - setFocusedDocumentId(null); - }, [ + useWorkspaceSelectionSync({ showingSearchResults, searchQuery, setSelectedEntries, @@ -497,7 +486,11 @@ const useDocumentsWorkspace = ({ selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, - ]); + selectedDocumentIds, + activePreviewId, + setActivePreviewId, + selectionInitializedRef, + }); const { tags, @@ -506,59 +499,17 @@ const useDocumentsWorkspace = ({ handleTagUpdate, handleTagDelete, setTags, - } = useTags({ - apiClient, - notifyApiError, - setStatusMessage, - tagManager, - tenantIdRef, - setActiveTagFilters, - mapDocumentCaches, - }); - - - useEffect(() => { - tenantIdRef.current = currentTenantId; - }, [currentTenantId]); - - - useEffect(() => { - if (!selectedDocumentIds.length) { - return; - } - if (!selectedDocumentIds.includes(activePreviewId)) { - setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); - } - selectionInitializedRef.current = true; - }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]); - - - const tagLookupById = useMemo(() => { - const map = new Map(); - tags.forEach((tag) => { - if (tag?.id) { - map.set(tag.id, tag); - } - }); - return map; - }, [tags]); - - const { + tagLookupById, correspondents, refreshCorrespondents, handleCorrespondentCreate, handleCorrespondentUpdate, handleCorrespondentDelete, setCorrespondents, - } = useCorrespondents({ - apiClient, - notifyApiError, - setStatusMessage, - tenantIdRef, - mapDocumentCaches, - }); - - const { + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, passkeys, passkeysSupported, passkeysLoading, @@ -567,9 +518,16 @@ const useDocumentsWorkspace = ({ refreshPasskeys, registerPasskey, revokePasskey, - } = usePasskeys({ + } = useWorkspaceTaxonomies({ + apiClient, notifyApiError, setStatusMessage, + tagManager, + tenantIdRef, + currentTenantId, + setActiveTagFilters, + mapDocumentCaches, + updateDocumentCaches, token, }); @@ -647,20 +605,6 @@ const useDocumentsWorkspace = ({ documentsViewMode, }); - const { - correspondentLookupByName, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - } = useDocumentCorrespondentActions({ - apiClient, - correspondents, - handleCorrespondentCreate, - notifyApiError, - setStatusMessage, - updateDocumentCaches, - }); - useEffect(() => { if (!activeSortRefreshReadyRef.current) { activeSortRefreshReadyRef.current = true; @@ -1270,85 +1214,13 @@ const useDocumentsWorkspace = ({ }, }); - 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]); + const breadcrumbs = useWorkspaceBreadcrumbs({ + selectedFolder, + folderNodes, + currentFolder, + breadcrumbFetchRef, + ensureFolderData, + }); const { handleTenantSelect } = useTenantManager({ apiClient, @@ -1367,89 +1239,27 @@ const useDocumentsWorkspace = ({ }); - const handleDeskDocumentStackSelect = useCallback( - (docIds: Array) => { - if (!Array.isArray(docIds) || docIds.length === 0) { - return; - } - - const rowKeys = docIds - .map((id) => resolveDocumentRowKey(id as Identifier)) - .filter((value): value is string => typeof value === 'string'); - - if (!rowKeys.length) { - return; - } - - const nextKeys = [...selectedEntries]; - rowKeys.forEach((key) => { - if (!nextKeys.includes(key)) { - nextKeys.push(key); - } - }); - - const anchor = (rowKeys[0] - || selectionAnchorRef.current - || nextKeys[nextKeys.length - 1]) as Identifier | string | null; - - applySelection(nextKeys, { - anchor, - interactedKeys: rowKeys, - }); - }, - [applySelection, selectedEntries, selectionAnchorRef], - ); - - const deskViewId = useMemo(() => { - if (showingSearchResults) { - const trimmedQuery = searchQuery.trim(); - const tagsKey = [...activeTagFilters].sort().join(','); - const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); - return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; - } - - const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; - return `folder:${folderKey}`; - }, [ + const deskWorkspaceProps = useWorkspaceDeskProps({ + viewDocuments, + inspectDocumentForDesk, + handleEntryPointer: handleEntryPointerCore, + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, showingSearchResults, searchQuery, activeTagFilters, activeCorrespondentFilters, selectedFolder, - ]); - - const deskWorkspaceProps = useMemo( - () => ({ - entries: viewDocuments, - onDocumentActivate: inspectDocumentForDesk, - onDocumentClick: handleEntryPointerCore, - onDocumentStackSelect: handleDeskDocumentStackSelect, - onPromoteSelection: promoteSelectionOrder, - onDocumentTagDrop: handleDocumentTagDrop, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - tenantId: currentTenantId, - viewId: deskViewId, - documentLinks, - ensureDownloadUrl, - }), - [ - viewDocuments, - inspectDocumentForDesk, - handleEntryPointerCore, - handleDeskDocumentStackSelect, - promoteSelectionOrder, - handleDocumentTagDrop, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - currentTenantId, - deskViewId, - documentLinks, - ensureDownloadUrl, - ], - ); + promoteSelectionOrder, + handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + currentTenantId, + documentLinks, + ensureDownloadUrl, + }); const documentsPanelProps = useDocumentsPanelProps({ currentFolderName, diff --git a/frontend/src/hooks/documents/useWorkspaceBreadcrumbs.ts b/frontend/src/hooks/documents/useWorkspaceBreadcrumbs.ts new file mode 100644 index 0000000..b1b7922 --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceBreadcrumbs.ts @@ -0,0 +1,105 @@ +import React, { useEffect, useMemo } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../../app/appLayoutUtils'; + +type Identifier = string | number; +type FolderId = Identifier | 'root'; + +interface UseWorkspaceBreadcrumbsArgs { + selectedFolder: FolderId | null; + folderNodes: Map; + currentFolder: { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null } | null; + breadcrumbFetchRef: React.MutableRefObject>; + ensureFolderData: (folderId: FolderId, options?: Record) => Promise; +} + +const useWorkspaceBreadcrumbs = ({ + selectedFolder, + folderNodes, + currentFolder, + breadcrumbFetchRef, + ensureFolderData, +}: UseWorkspaceBreadcrumbsArgs) => { + const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { + const chain: Array<{ id: FolderId; name?: string | null }> = []; + const seen = new Set(); + const pending = new Set(); + let currentId: FolderId | null = (selectedFolder || 'root') as FolderId; + 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 as FolderId); + if (node) { + chain.push({ id: currentId, name: node.name || 'Folder' }); + currentId = (node.parentId ?? node.parent_id ?? 'root') as FolderId; + continue; + } + + let fallbackName: string | null | undefined = '…'; + let parentId: FolderId | null | undefined = null; + + if (currentFolder && currentFolder.id === currentId) { + fallbackName = currentFolder.name; + parentId = (currentFolder.parent_id ?? currentFolder.parentId ?? 'root') as FolderId; + } + + chain.push({ id: currentId, name: fallbackName }); + pending.add(currentId); + currentId = parentId as FolderId | null; + } + + if (!chain.some((crumb) => crumb.id === 'root')) { + chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + } + + const ordered: Array<{ id: FolderId; name?: string | null }> = []; + 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, breadcrumbFetchRef]); + + return breadcrumbs; +}; + +export default useWorkspaceBreadcrumbs; diff --git a/frontend/src/hooks/documents/useWorkspaceDeskProps.ts b/frontend/src/hooks/documents/useWorkspaceDeskProps.ts new file mode 100644 index 0000000..655b969 --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceDeskProps.ts @@ -0,0 +1,136 @@ +import { useCallback, useMemo } from 'react'; +import type { MutableRefObject } from 'react'; + +type Identifier = string | number; + +interface UseWorkspaceDeskPropsArgs { + viewDocuments: any[]; + inspectDocumentForDesk: (doc: any) => void; + handleEntryPointer: (params: { rowKey?: string | null; id?: Identifier | null; type?: string; event?: any }) => void; + selectedEntries: Array; + selectionAnchorRef: MutableRefObject; + applySelection: (rowKeys: Array, options?: { anchor?: Identifier | string | null; interactedKeys?: Array }) => void; + resolveDocumentRowKey: (id?: Identifier | null) => string | null; + showingSearchResults: boolean; + searchQuery: string; + activeTagFilters: Array; + activeCorrespondentFilters: Array; + selectedFolder: Identifier | 'root' | null; + promoteSelectionOrder: () => void; + handleDocumentTagDrop: (docId: Identifier, tagId: Identifier) => Promise | void; + ensureAssetUrl: (docId: Identifier, asset: any, options?: Record) => Promise | null; + getDocumentAsset: (doc: any, type: string) => any; + currentTenantId: Identifier | null; + documentLinks: Map | null; + ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise; +} + +const useWorkspaceDeskProps = ({ + viewDocuments, + inspectDocumentForDesk, + handleEntryPointer, + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + promoteSelectionOrder, + handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + currentTenantId, + documentLinks, + ensureDownloadUrl, +}: UseWorkspaceDeskPropsArgs) => { + const handleDeskDocumentStackSelect = useCallback( + (docIds: Array) => { + if (!Array.isArray(docIds) || docIds.length === 0) { + return; + } + + const rowKeys = docIds + .map((id) => resolveDocumentRowKey(id as Identifier)) + .filter((value): value is string => typeof value === 'string'); + + if (!rowKeys.length) { + return; + } + + const nextKeys = [...selectedEntries]; + rowKeys.forEach((key) => { + if (!nextKeys.includes(key)) { + nextKeys.push(key); + } + }); + + const anchor = (rowKeys[0] + || selectionAnchorRef.current + || nextKeys[nextKeys.length - 1]) as Identifier | string | null; + + applySelection(nextKeys, { + anchor, + interactedKeys: rowKeys, + }); + }, + [applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef], + ); + + const deskViewId = useMemo(() => { + if (showingSearchResults) { + const trimmedQuery = searchQuery.trim(); + const tagsKey = [...activeTagFilters].sort().join(','); + const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); + return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; + } + + const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; + return `folder:${folderKey}`; + }, [ + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + ]); + + const deskWorkspaceProps = useMemo( + () => ({ + entries: viewDocuments, + onDocumentActivate: inspectDocumentForDesk, + onDocumentClick: handleEntryPointer, + onDocumentStackSelect: handleDeskDocumentStackSelect, + onPromoteSelection: promoteSelectionOrder, + onDocumentTagDrop: handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + tenantId: currentTenantId, + viewId: deskViewId, + documentLinks, + ensureDownloadUrl, + }), + [ + viewDocuments, + inspectDocumentForDesk, + handleEntryPointer, + handleDeskDocumentStackSelect, + promoteSelectionOrder, + handleDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + currentTenantId, + deskViewId, + documentLinks, + ensureDownloadUrl, + ], + ); + + return deskWorkspaceProps; +}; + +export default useWorkspaceDeskProps; diff --git a/frontend/src/hooks/documents/useWorkspaceSelectionSync.ts b/frontend/src/hooks/documents/useWorkspaceSelectionSync.ts new file mode 100644 index 0000000..9f3236a --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceSelectionSync.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; + +type Identifier = string | number; + +interface UseWorkspaceSelectionSyncArgs { + showingSearchResults: boolean; + searchQuery: string; + setSelectedEntries: (entries: Array) => void; + setSelectionOrder: (order: Array) => void; + selectionOrderRef: MutableRefObject>; + selectionAnchorRef: MutableRefObject; + setFocusedDocumentId: (id: Identifier | null) => void; + selectedDocumentIds: Identifier[]; + activePreviewId: Identifier | null; + setActivePreviewId: (id: Identifier | null) => void; + selectionInitializedRef: MutableRefObject; +} + +const useWorkspaceSelectionSync = ({ + showingSearchResults, + searchQuery, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + selectedDocumentIds, + activePreviewId, + setActivePreviewId, + selectionInitializedRef, +}: UseWorkspaceSelectionSyncArgs) => { + useEffect(() => { + if (!showingSearchResults) { + return; + } + setSelectedEntries([]); + setSelectionOrder([]); + selectionOrderRef.current = []; + selectionAnchorRef.current = null; + setFocusedDocumentId(null); + }, [ + showingSearchResults, + searchQuery, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + ]); + + useEffect(() => { + if (!selectedDocumentIds.length) { + return; + } + if (!selectedDocumentIds.includes(activePreviewId as Identifier)) { + setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); + } + selectionInitializedRef.current = true; + }, [selectedDocumentIds, activePreviewId, selectionInitializedRef, setActivePreviewId]); +}; + +export default useWorkspaceSelectionSync; diff --git a/frontend/src/hooks/documents/useWorkspaceTaxonomies.ts b/frontend/src/hooks/documents/useWorkspaceTaxonomies.ts new file mode 100644 index 0000000..f77756c --- /dev/null +++ b/frontend/src/hooks/documents/useWorkspaceTaxonomies.ts @@ -0,0 +1,140 @@ +import { useEffect, useMemo } from 'react'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import usePasskeys from '../../settings/usePasskeys'; +import TagManager from '../../tag_manager'; +import useCorrespondents from './useCorrespondents'; +import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; +import useTags from './useTags'; + +type Identifier = string | number; + +interface UseWorkspaceTaxonomiesArgs { + apiClient: any; + notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void; + setStatusMessage: (message: string, variant?: string) => void; + tagManager: TagManager; + tenantIdRef: MutableRefObject; + currentTenantId: Identifier | null; + setActiveTagFilters: Dispatch>; + mapDocumentCaches: (mapper: (doc: any) => any | undefined) => void; + updateDocumentCaches: (id: Identifier, updater: (doc: any) => any) => void; + token: string; +} + +const useWorkspaceTaxonomies = ({ + apiClient, + notifyApiError, + setStatusMessage, + tagManager, + tenantIdRef, + currentTenantId, + setActiveTagFilters, + mapDocumentCaches, + updateDocumentCaches, + token, +}: UseWorkspaceTaxonomiesArgs) => { + const { + tags, + refreshTags, + handleTagCreate, + handleTagUpdate, + handleTagDelete, + setTags, + } = useTags({ + apiClient, + notifyApiError, + setStatusMessage, + tagManager, + tenantIdRef, + setActiveTagFilters, + mapDocumentCaches, + }); + + useEffect(() => { + tenantIdRef.current = currentTenantId; + }, [currentTenantId, tenantIdRef]); + + const tagLookupById = useMemo(() => { + const map = new Map(); + tags.forEach((tag) => { + if (tag?.id) { + map.set(tag.id, tag); + } + }); + return map; + }, [tags]); + + const { + correspondents, + refreshCorrespondents, + handleCorrespondentCreate, + handleCorrespondentUpdate, + handleCorrespondentDelete, + setCorrespondents, + } = useCorrespondents({ + apiClient, + notifyApiError, + setStatusMessage, + tenantIdRef, + mapDocumentCaches, + }); + + const { + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, + } = useDocumentCorrespondentActions({ + apiClient, + correspondents, + handleCorrespondentCreate, + notifyApiError, + setStatusMessage, + updateDocumentCaches, + }); + + const { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + } = usePasskeys({ + notifyApiError, + setStatusMessage, + token, + }); + + return { + tags, + refreshTags, + handleTagCreate, + handleTagUpdate, + handleTagDelete, + setTags, + tagLookupById, + correspondents, + refreshCorrespondents, + handleCorrespondentCreate, + handleCorrespondentUpdate, + handleCorrespondentDelete, + setCorrespondents, + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleCorrespondentRemove, + handleCorrespondentAdd, + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + }; +}; + +export default useWorkspaceTaxonomies; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 0b25cba..5cc80ba 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -2,10 +2,93 @@ import '@fontsource/inter/400.css'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { HashRouter } from 'react-router-dom'; +import { + HashRouter, + Navigate, + Outlet, + Route, + Routes, +} from 'react-router-dom'; import './styles/index.css'; +import DocumentsRoute from './app/DocumentsRoute'; +import DropOverlay from './app/DropOverlay'; +import LoginRoute from './app/LoginRoute'; +import SettingsRoute from './app/SettingsRoute'; import { AppStateProvider } from './app/appState'; -import AppRouter from './app/AppRouter'; +import { useDocumentsPreferences } from './app/useDocumentsPreferences'; +import { AppShellContext } from './appShellContext'; +import useDocumentsWorkspace from './hooks/documents/useDocumentsWorkspace'; +import UploadQueueOverlay from './app/UploadQueueOverlay'; + +const AppLayout: React.FC = () => { + const documentsPreferences = useDocumentsPreferences(); + const { + appStatus, + location, + shellRef, + dropOverlayState, + managementModals, + contextValue, + settingsOpen, + closeSettings, + } = useDocumentsWorkspace({ + documentsViewMode: documentsPreferences.documentsViewMode, + documentsSortField: documentsPreferences.documentsSortField, + documentsSortDirection: documentsPreferences.documentsSortDirection, + documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, + documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, + onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, + onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, + onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, + searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, + onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, + sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, + }); + + if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`; + return ( + + ); + } + + return ( + +
+ + + + {managementModals} + {settingsOpen ? ( + + ) : null} +
+
+ ); +}; + +const AppRouter: React.FC = () => ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + +); const container = document.getElementById('app'); diff --git a/frontend/src/types/assets.d.ts b/frontend/src/types/assets.d.ts deleted file mode 100644 index fdf3731..0000000 --- a/frontend/src/types/assets.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*?url' { - const url: string; - export default url; -} diff --git a/frontend/src/utils/math.ts b/frontend/src/utils/math.ts index 3547bb3..1d28378 100644 --- a/frontend/src/utils/math.ts +++ b/frontend/src/utils/math.ts @@ -8,6 +8,14 @@ export const clamp = (value: number, min: number, max: number): number => { return value; }; +export const formatTransform = ( + x: number, + y: number, + rotation = 0, + scale = 1, +): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; + export default { clamp, + formatTransform, }; diff --git a/frontend/src/utils/typeGuards.ts b/frontend/src/utils/typeGuards.ts index e441cae..ca9260f 100644 --- a/frontend/src/utils/typeGuards.ts +++ b/frontend/src/utils/typeGuards.ts @@ -5,6 +5,3 @@ export const isPlainObject = (value: unknown): value is Record export const isStringValue = (value: unknown): value is string => objectToString.call(value) === '[object String]'; - -export const isFunctionValue = unknown>(value: unknown): value is T => - objectToString.call(value) === '[object Function]';