diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index c3ec2db..81fcb80 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { SidebarExpandIcon } from '../ui/icons'; import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; -import { createDesktopSurface } from '../desktop/DesktopWorkspace'; +import createDesktopSurface from '../desktop/createDesktopSurface'; export const useWorkspaceSurface = ({ sidebarCollapsed, diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.jsx index ad65cd2..37c794d 100644 --- a/frontend/src/desktop/DesktopWorkspace.jsx +++ b/frontend/src/desktop/DesktopWorkspace.jsx @@ -8,12 +8,8 @@ import React, { useSyncExternalStore, } from 'react'; import { createPortal } from 'react-dom'; -import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; +import { resolveDocumentAssetUrl } from '../asset_manager'; import { CloseIcon } from '../ui/icons'; -import SelectionFloatingActions from '../documents/SelectionFloatingActions'; -import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; -import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; import { formatTransform } from './math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; @@ -29,91 +25,11 @@ import { import useDeskPointer from './pointer/useDeskPointer'; import useDeskTagInteractions from './tags/useDeskTagInteractions'; import DesktopDocumentCard from './DesktopDocumentCard'; +import usePreviewMetadata from './hooks/usePreviewMetadata'; import './DesktopWorkspace.css'; const DEBUG_DRAG = false; const DEBUG_FOCUS = false; - -const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { - const [metadataMap, setMetadataMap] = useState(() => new Map()); - - useEffect(() => { - let cancelled = false; - const docs = Array.isArray(documents) ? documents : []; - if (!docs.length) { - setMetadataMap(new Map()); - return () => { - cancelled = true; - }; - } - - const fetchMetadataForDoc = async (doc) => { - if (!doc?.id) { - return null; - } - - const docId = String(doc.id); - const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); - - let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); - - const hasDimensions = (meta) => - Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && - Number(meta.width) > 0 && Number(meta.height) > 0; - - if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { - try { - const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); - if (ensured) { - asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); - } - } catch (error) { - console.warn('[desk] ensureDocumentSize metadata fetch failed', error); - } - } - - if (!hasDimensions(metadata)) { - return null; - } - - const width = Number(metadata.width); - const height = Number(metadata.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return [docId, { width, height }]; - }; - - Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) - .then((entries) => { - if (cancelled) return; - const next = new Map(); - entries.forEach((entry) => { - if (entry) { - next.set(entry[0], entry[1]); - } - }); - setMetadataMap(next); - }) - .catch(() => { - if (!cancelled) { - setMetadataMap(new Map()); - } - }); - - return () => { - cancelled = true; - }; - }, [documents, getDocumentAsset, ensureAssetUrl]); - - return metadataMap; -}; - const DesktopWorkspace = ({ documents = [], searchResults = null, @@ -1082,109 +998,3 @@ const DesktopHelpOverlay = ({ open = false, onClose = null }) => { portalTarget, ); }; - -export const createDesktopSurface = ({ - workspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailProps = null, - detailOpen = false, -}) => { - if (!workspaceProps) { - return null; - } - - const { - currentFolderName, - searchResults, - onRefresh, - viewMode, - onViewModeChange, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - } = workspaceProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - const actions = createDocumentsTableHeaderActions({ - viewMode: viewMode || 'desk', - onViewModeChange, - onRefresh, - onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'workspace', - variant: 'workspace', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs: workspaceProps?.breadcrumbs || null, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return { - ...surfaceConfig, - supportsDetail: Boolean(detailProps), - }; -}; diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.js new file mode 100644 index 0000000..50a860a --- /dev/null +++ b/frontend/src/desktop/createDesktopSurface.js @@ -0,0 +1,113 @@ +import React from 'react'; +import SelectionFloatingActions from '../documents/SelectionFloatingActions'; +import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; +import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; +import DetailPanel from '../detail/DetailPanel'; +import DesktopWorkspace from './DesktopWorkspace'; + +const createDesktopSurface = ({ + workspaceProps, + renderSidebarToggle, + parentBreadcrumb, + onNavigateParent, + detailProps = null, + detailOpen = false, +}) => { + if (!workspaceProps) { + return null; + } + + const { + currentFolderName, + searchResults, + onRefresh, + viewMode, + onViewModeChange, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + } = workspaceProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + const actions = createDocumentsTableHeaderActions({ + viewMode: viewMode || 'desk', + onViewModeChange, + onRefresh, + onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + const surfaceConfig = createWorkspaceSurfaceConfig({ + key: 'workspace', + variant: 'workspace', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs: workspaceProps?.breadcrumbs || null, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); + + return { + ...surfaceConfig, + supportsDetail: Boolean(detailProps), + }; +}; + +export default createDesktopSurface; diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.js b/frontend/src/desktop/hooks/usePreviewMetadata.js new file mode 100644 index 0000000..6078c21 --- /dev/null +++ b/frontend/src/desktop/hooks/usePreviewMetadata.js @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { createAssetView } from '../../asset_manager'; + +const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { + const [metadataMap, setMetadataMap] = useState(() => new Map()); + + useEffect(() => { + let cancelled = false; + const docs = Array.isArray(documents) ? documents : []; + if (!docs.length) { + setMetadataMap(new Map()); + return () => { + cancelled = true; + }; + } + + const fetchMetadataForDoc = async (doc) => { + if (!doc?.id) { + return null; + } + + const docId = String(doc.id); + const resolveAsset = (type) => + (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); + + let asset = resolveAsset('preview') || resolveAsset('thumbnail'); + let view = createAssetView(asset); + let metadata = view.getPrimaryMetadata(); + + const hasDimensions = (meta) => + Number.isFinite(Number(meta?.width)) && + Number.isFinite(Number(meta?.height)) && + Number(meta.width) > 0 && + Number(meta.height) > 0; + + if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { + try { + const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); + if (ensured) { + asset = ensured; + view = createAssetView(asset); + metadata = view.getPrimaryMetadata(); + } + } catch (error) { + console.warn('[desk] ensureDocumentSize metadata fetch failed', error); + } + } + + if (!hasDimensions(metadata)) { + return null; + } + + return { + docId, + width: Number(metadata.width), + height: Number(metadata.height), + }; + }; + + let mounted = true; + (async () => { + const entries = await Promise.all(docs.map(fetchMetadataForDoc)); + if (!mounted || cancelled) { + return; + } + const next = new Map(); + entries.forEach((entry) => { + if (entry && entry.docId) { + next.set(entry.docId, entry); + } + }); + if (!cancelled) { + setMetadataMap(next); + } + })(); + + return () => { + cancelled = true; + mounted = false; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + +export default usePreviewMetadata; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index 740b423..d076f21 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -1,1056 +1,3 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ViewListIcon, - ViewGridIcon, - IconFileStack, - RefreshIcon, - MinusVerticalIcon, - InfoIcon, - FoldersIcon, - FoldersOffIcon, - SortAscendingLettersIcon, - SortDescendingLettersIcon, -} from '../ui/icons'; -import QuickAddMenu from '../ui/QuickAddMenu'; -import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -import createWorkspaceSurfaceConfig from './workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; -import DocumentsGrid from './DocumentsGrid'; -import DocumentsList from './DocumentsList'; -import { isTagTransferEvent } from './tagTransfer'; -import SelectionFloatingActions from './SelectionFloatingActions'; -import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; -import { useAssetNavigator } from '../hooks/useAssetNavigator'; -import { isPointerModifierEvent, isPrimaryPointerEvent } from './useEntryPointer'; - -const DEFAULT_GRID_ICON_SIZE = 144; - -const EntryType = { - folder: 'folder', - document: 'document', -}; - -const SORT_OPTIONS = [ - { value: 'title', label: 'Title' }, - { value: 'issued_at', label: 'Issued date' }, - { value: 'created_at', label: 'Added' }, - { value: 'updated_at', label: 'Updated date' }, -]; - -const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => { - const next = accumulator; - next[option.value] = option.label; - return next; -}, {}); - -const DocumentsPanel = ({ - currentFolderName, - breadcrumbs, - onRefresh, - subfolders, - documents, - searchResults, - isFilterActive = false, - onFolderSelect, - onFolderDrop, - onFolderDragOver, - onFolderDragLeave, - onFolderDragStart, - onFolderDragEnd, - draggedFolderId, - onFolderRename, - selectedFolderIds = [], - selectedDocumentIds = [], - focusedRowKey, - draggingDocumentIds = [], - onDocumentDragStart, - onDocumentDragEnd, - onDocumentRename, - onEntryPointer = null, - onEntrySelection = null, - onInspectDocument = null, - tagLookupById, - activeCorrespondentIds = [], - onFocusedRowChange, - ensureAssetUrl = null, - getDocumentAsset = () => null, - onTagClick, - onCorrespondentClick, - isSearchLoading = false, - onDocumentTagDrop, - viewMode = 'list', - onViewModeChange, - onClearSelection, - selectedEntries = [], - showHeader = true, -}) => { - const showingSearchResults = searchResults !== null; - const rows = showingSearchResults ? searchResults : documents; - - const entries = useMemo(() => { - const list = []; - if (!showingSearchResults) { - subfolders.forEach((folder) => { - if (!folder || !folder.id) { - return; - } - list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); - }); - } - rows.forEach((doc) => { - if (!doc || !doc.id) { - return; - } - list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); - }); - return list; - }, [showingSearchResults, subfolders, rows]); - - const selectedSet = useMemo( - () => new Set(selectedDocumentIds), - [selectedDocumentIds], - ); - const selectedFolderSet = useMemo( - () => new Set(selectedFolderIds || []), - [selectedFolderIds], - ); - const draggingSet = useMemo( - () => new Set(draggingDocumentIds || []), - [draggingDocumentIds], - ); - const activeCorrespondentIdSet = useMemo( - () => new Set(activeCorrespondentIds || []), - [activeCorrespondentIds], - ); - const scrollRef = useRef(null); - const suppressDocumentClickRef = useRef(false); - const [, forceVisibilityTick] = useState(0); - const lastScrollNodeRef = useRef(null); - const assignScrollRef = useCallback((node) => { - if (lastScrollNodeRef.current === node) { - return; - } - lastScrollNodeRef.current = node; - scrollRef.current = node; - if (node) { - forceVisibilityTick((value) => value + 1); - } - }, []); - const isGridView = viewMode === 'grid'; - const isDeskView = viewMode === 'desk'; - - const [previewDocId, setPreviewDocId] = useState(null); - - const previewDoc = useMemo(() => { - if (!previewDocId) { - return null; - } - return rows.find((doc) => doc?.id === previewDocId) || null; - }, [previewDocId, rows]); - - useEffect(() => { - if (previewDocId && !previewDoc) { - setPreviewDocId(null); - } - }, [previewDocId, previewDoc]); - - const previewNavigator = useAssetNavigator({ - document: previewDoc, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); - - const { - currentUrl: previewUrl, - canGoPrev: previewCanGoPrev, - canGoNext: previewCanGoNext, - goPrev: previewGoPrev, - goNext: previewGoNext, - } = previewNavigator; - - const previewDisplay = useMemo(() => { - if (!previewDoc || !previewUrl) { - return null; - } - return { - url: previewUrl, - alt: previewDoc.title, - canGoPrev: Boolean(previewCanGoPrev), - canGoNext: Boolean(previewCanGoNext), - goPrev: previewGoPrev, - goNext: previewGoNext, - }; - }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); - - const closePreviewOverlay = useCallback(() => { - setPreviewDocId(null); - }, []); - - const handleDocumentPreviewZoom = useCallback( - (doc) => { - if (!doc || !doc.id) { - return; - } - const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; - if (!previewAsset) { - return; - } - setPreviewDocId(doc.id); - }, - [getDocumentAsset], - ); - - const handleDocumentActivate = useCallback( - (doc, event) => { - if (!doc) { - return; - } - if (event) { - if (typeof event.preventDefault === 'function') { - event.preventDefault(); - } - if (typeof event.stopPropagation === 'function') { - event.stopPropagation(); - } - } - if (event?.altKey) { - handleDocumentPreviewZoom(doc); - return; - } - onInspectDocument?.(doc.id, event); - }, - [handleDocumentPreviewZoom, onInspectDocument], - ); - - const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); - const navigableRows = useMemo( - () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), - [entries], - ); - const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); - - const getEntryByKey = useCallback( - (rowKey) => entries.find((entry) => entry.key === rowKey) || null, - [entries], - ); - - const handlePanelFocus = useCallback(() => { - let resolvedKey = null; - - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - resolvedKey = focusedRowKey; - } - - if (!resolvedKey) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - resolvedKey = candidate; - break; - } - } - } - - if (!resolvedKey && navigableRows.length) { - resolvedKey = navigableRows[0].key; - } - - if (!resolvedKey) { - return; - } - - onFocusedRowChange?.(resolvedKey); - - if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { - onEntrySelection(resolvedKey, { - shiftKey: false, - preventDefault: () => {}, - }); - } - }, [ - focusedRowKey, - navigableRowKeys, - navigableRows, - onEntrySelection, - onFocusedRowChange, - selectedEntries, - selectedRowKeySet, - ]); - - const handlePanelKeyDown = 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) { - if (selectedEntries.length) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - activeKey = candidate; - break; - } - } - } - - if (!activeKey) { - activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; - } - } - - const currentIndex = navigableRowKeys.indexOf(activeKey); - const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; - - if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { - if (activeRow) { - onEntrySelection?.(activeRow.key, event); - if (activeRow.type === EntryType.folder) { - onFolderSelect?.(activeRow.id); - } else { - const entry = getEntryByKey(activeRow.key); - if (entry?.document) { - handleDocumentPreviewZoom(entry.document); - } - } - } - 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; - } - - const targetRow = navigableRows[nextIndex]; - if (!targetRow) { - return; - } - - onFocusedRowChange?.(targetRow.key); - onEntrySelection?.(targetRow.key, { - shiftKey, - preventDefault: () => {}, - }); - }, - [ - focusedRowKey, - getEntryByKey, - navigableRowKeys, - navigableRows, - onEntrySelection, - onFocusedRowChange, - onFolderSelect, - selectedEntries, - handleDocumentPreviewZoom, - ], - ); - - const isListView = viewMode === 'list'; - const gridIconSize = DEFAULT_GRID_ICON_SIZE; - const handleSetViewMode = useCallback( - (nextMode) => { - if (!onViewModeChange) { - return; - } - onViewModeChange(nextMode); - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, - [onViewModeChange], - ); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [viewMode]); - const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); - const ensureFocusedRowVisible = useCallback(() => { - if (!focusedRowKey) return; - const container = scrollRef.current; - if (!container) return; - let selector = null; - if (focusedRowKey.startsWith('document:')) { - selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; - } else if (focusedRowKey.startsWith('folder:')) { - selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; - } - if (!selector) { - return; - } - const row = container.querySelector(selector); - if (!row || !container.contains(row)) { - return; - } - - const header = container.querySelector('thead'); - const headerHeight = header ? header.getBoundingClientRect().height : 0; - const rowTop = row.offsetTop; - const rowBottom = rowTop + row.offsetHeight; - const visibleTop = container.scrollTop + headerHeight; - const visibleBottom = container.scrollTop + container.clientHeight; - - if (rowTop < visibleTop) { - container.scrollTop = Math.max(rowTop - headerHeight, 0); - return; - } - - if (rowBottom > visibleBottom) { - const nextScrollTop = rowBottom - container.clientHeight; - container.scrollTop = Math.max(nextScrollTop, 0); - } - }, [focusedRowKey]); - - useEffect(() => { - ensureFocusedRowVisible(); - }, [ensureFocusedRowVisible]); - - const activeDescendantId = useMemo(() => { - if (!focusedRowKey) return undefined; - if (focusedRowKey.startsWith('document:')) { - return `document-row-${focusedRowKey.slice('document:'.length)}`; - } - if (focusedRowKey.startsWith('folder:')) { - return `folder-row-${focusedRowKey.slice('folder:'.length)}`; - } - return undefined; - }, [focusedRowKey]); - - const handleDocumentTagDragOver = useCallback( - (event) => { - if (!isTagDragEvent(event)) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - event.currentTarget.classList.add('tag-drop-target'); - }, - [isTagDragEvent], - ); - - const handleDocumentTagDragLeave = useCallback( - (event) => { - if (!isTagDragEvent(event)) { - return; - } - if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { - return; - } - event.currentTarget.classList.remove('tag-drop-target'); - }, - [isTagDragEvent], - ); - - const handleDocumentTagDrop = useCallback( - (event, documentId) => { - if (!isTagDragEvent(event)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove('tag-drop-target'); - const payload = - event.dataTransfer.getData('application/x-papercrate-tag') || - event.dataTransfer.getData('text/papercrate-tag'); - if (!payload) { - return; - } - try { - const parsed = JSON.parse(payload); - if (parsed?.id && onDocumentTagDrop) { - onDocumentTagDrop(documentId, parsed); - } - } catch (error) { - console.warn('[documents] Failed to parse tag drop payload', error); - } - }, - [isTagDragEvent, onDocumentTagDrop], - ); - - const handleDocumentClick = useCallback( - (doc, event) => { - if (!doc || suppressDocumentClickRef.current) { - return; - } - - if (typeof onEntryPointer === 'function') { - onEntryPointer( - { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, - event, - ); - } - }, - [onEntryPointer], - ); - - const handleFolderClick = useCallback( - (folder, event) => { - if (!folder) { - return; - } - - if (typeof onEntryPointer === 'function') { - onEntryPointer( - { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, - event, - ); - } - - if ( - !isPointerModifierEvent(event) - && isPrimaryPointerEvent(event) - && scrollRef.current - ) { - scrollRef.current.focus({ preventScroll: true }); - onFocusedRowChange?.(`folder:${folder.id}`); - } - }, - [onEntryPointer, onFocusedRowChange], - ); - - const handleDocumentDragStartLocal = useCallback( - (event, doc) => { - suppressDocumentClickRef.current = true; - onDocumentDragStart?.(event, doc); - }, - [onDocumentDragStart], - ); - - const handleDocumentDragEndLocal = useCallback( - (event) => { - onDocumentDragEnd?.(event); - requestAnimationFrame(() => { - suppressDocumentClickRef.current = false; - }); - }, - [onDocumentDragEnd], - ); - - const hasDocumentEntries = useMemo( - () => entries.some((entry) => entry.type === EntryType.document), - [entries], - ); - const showTableRows = entries.length > 0; - const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; - const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; - const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; - const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); - const trailEntries = useMemo(() => { - if (!breadcrumbEntries.length) { - return [{ id: 'current-folder', label: currentFolderName }]; - } - const lastIndex = breadcrumbEntries.length - 1; - return breadcrumbEntries.map((crumb, index) => ({ - id: crumb.id ?? index, - label: crumb.name ?? crumb.label ?? crumb.title ?? '', - onClick: index < lastIndex && onFolderSelect - ? () => onFolderSelect(crumb.id) - : null, - })); - }, [breadcrumbEntries, currentFolderName, onFolderSelect]); - - return ( - <> -
- {showHeader ? ( -
-
-

- -

- {showingSearchResults && ( -
Search results
- )} -
-
-
- - - -
- -
-
- ) : null} - {showDefaultEmptyState ? ( -
-
- Drop files anywhere or onto a folder to upload documents. -
-
- ) : showGridSearchEmptyState ? ( -
-
- No documents match the current filters. -
-
- ) : showListSearchEmptyState ? ( -
-
No documents match the current filters.
-
- ) : ( -
-
{ - if (event.target === scrollRef.current) { - handlePanelFocus(); - } - }} - onKeyDown={(event) => { - if (event.target !== scrollRef.current) { - return; - } - handlePanelKeyDown(event); - }} - onClick={(event) => { - if (event.target === event.currentTarget) { - onClearSelection?.(); - } - }} - aria-activedescendant={isGridView ? undefined : activeDescendantId} - > - {isGridView ? ( - - ) : !showTableRows ? null : ( - - )} -
-
- )} -
- - - ); -}; - -export default DocumentsPanel; - -const SortFieldQuickMenu = ({ sortField, onChange }) => { - const currentOption = useMemo( - () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], - [sortField], - ); - - const options = useMemo( - () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), - [], - ); - - const handleSelect = useCallback( - (value, option) => { - if (typeof onChange !== 'function') { - return; - } - const nextValue = option?.id || option?.original?.id || value; - if (nextValue) { - onChange(nextValue); - } - }, - [onChange], - ); - - const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; - - return ( - - {label} - - )} - triggerAriaLabel={`Sort by ${label}`} - triggerTitle={`Sort by ${label}`} - placeholder="Select sort field" - menuMinWidth={200} - align="start" - positionStrategy="absolute" - /> - ); -}; - -export const createDocumentsTableHeaderActions = ({ - viewMode, - onViewModeChange, - onRefresh, - onShowDeskHelp = null, - sortField = 'title', - onSortFieldChange = null, - sortDirection = 'asc', - onSortDirectionToggle = null, - isFilterActive = false, - includeDescendants = true, - onToggleIncludeDescendants = null, -}) => { - const isListView = viewMode === 'list'; - const isGridView = viewMode === 'grid'; - const isDeskView = viewMode === 'desk'; - - const sortDirectionIsDesc = sortDirection === 'desc'; - const sortDirectionTitle = sortDirectionIsDesc - ? 'Sorting Z → A. Click to switch to ascending.' - : 'Sorting A → Z. Click to switch to descending.'; - const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' - ? ( - - ) - : null; - - const sortControls = typeof onSortFieldChange === 'function' - ? ( -
- - {typeof onSortDirectionToggle === 'function' ? ( - - ) : null} -
- ) - : null; - - return ( - <> - {isDeskView && typeof onShowDeskHelp === 'function' ? ( - <> - - - - ) : null} - {includeDescendantsToggle ? ( - <> - {includeDescendantsToggle} - - - ) : null} - {sortControls ? ( - <> - {sortControls} - - - ) : null} -
- - - -
- - - - ); -}; - -export const createDocumentsSurface = ({ - tableProps, - parentBreadcrumb, - onNavigateParent, - renderSidebarToggle, - detailProps, - detailOpen = false, -}) => { - const { - currentFolderName, - breadcrumbs, - searchResults, - isFilterActive, - viewMode, - onViewModeChange, - onRefresh, - sortField, - sortDirection, - onSortFieldChange, - onSortDirectionToggle, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - onInspectDocument, - } = tableProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - - const actions = createDocumentsTableHeaderActions({ - viewMode, - onViewModeChange, - onRefresh, - sortField, - onSortFieldChange, - sortDirection, - onSortDirectionToggle, - isFilterActive, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'documents', - variant: 'documents', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return surfaceConfig; -}; +export { default } from './panel/DocumentsPanel'; +export { default as createDocumentsSurface } from './panel/createDocumentsSurface'; +export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar'; diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.jsx new file mode 100644 index 0000000..a5d307b --- /dev/null +++ b/frontend/src/documents/panel/DocumentsPanel.jsx @@ -0,0 +1,725 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ViewListIcon, ViewGridIcon, IconFileStack } from '../../ui/icons'; +import BreadcrumbTrail from '../../ui/BreadcrumbTrail'; +import DocumentsGrid from '../DocumentsGrid'; +import DocumentsList from '../DocumentsList'; +import { isTagTransferEvent } from '../tagTransfer'; +import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; +import { useAssetNavigator } from '../../hooks/useAssetNavigator'; +import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; + +const DEFAULT_GRID_ICON_SIZE = 144; + +const EntryType = { + folder: 'folder', + document: 'document', +}; + +const DocumentsPanel = ({ + currentFolderName, + breadcrumbs, + onRefresh, + subfolders, + documents, + searchResults, + isFilterActive = false, + onFolderSelect, + onFolderDrop, + onFolderDragOver, + onFolderDragLeave, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onFolderRename, + selectedFolderIds = [], + selectedDocumentIds = [], + focusedRowKey, + draggingDocumentIds = [], + onDocumentDragStart, + onDocumentDragEnd, + onDocumentRename, + onEntryPointer = null, + onEntrySelection = null, + onInspectDocument = null, + tagLookupById, + activeCorrespondentIds = [], + onFocusedRowChange, + ensureAssetUrl = null, + getDocumentAsset = () => null, + onTagClick, + onCorrespondentClick, + isSearchLoading = false, + onDocumentTagDrop, + viewMode = 'list', + onViewModeChange, + onClearSelection, + selectedEntries = [], + showHeader = true, +}) => { + const showingSearchResults = searchResults !== null; + const rows = showingSearchResults ? searchResults : documents; + + const entries = useMemo(() => { + const list = []; + if (!showingSearchResults) { + subfolders.forEach((folder) => { + if (!folder || !folder.id) { + return; + } + list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); + }); + } + rows.forEach((doc) => { + if (!doc || !doc.id) { + return; + } + list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); + }); + return list; + }, [showingSearchResults, subfolders, rows]); + + const selectedSet = useMemo( + () => new Set(selectedDocumentIds), + [selectedDocumentIds], + ); + const selectedFolderSet = useMemo( + () => new Set(selectedFolderIds || []), + [selectedFolderIds], + ); + const draggingSet = useMemo( + () => new Set(draggingDocumentIds || []), + [draggingDocumentIds], + ); + const activeCorrespondentIdSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + const scrollRef = useRef(null); + const suppressDocumentClickRef = useRef(false); + const [, forceVisibilityTick] = useState(0); + const lastScrollNodeRef = useRef(null); + const assignScrollRef = useCallback((node) => { + if (lastScrollNodeRef.current === node) { + return; + } + lastScrollNodeRef.current = node; + scrollRef.current = node; + if (node) { + forceVisibilityTick((value) => value + 1); + } + }, []); + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const [previewDocId, setPreviewDocId] = useState(null); + + const previewDoc = useMemo(() => { + if (!previewDocId) { + return null; + } + return rows.find((doc) => doc?.id === previewDocId) || null; + }, [previewDocId, rows]); + + useEffect(() => { + if (previewDocId && !previewDoc) { + setPreviewDocId(null); + } + }, [previewDocId, previewDoc]); + + const previewNavigator = useAssetNavigator({ + document: previewDoc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + + const { + currentUrl: previewUrl, + canGoPrev: previewCanGoPrev, + canGoNext: previewCanGoNext, + goPrev: previewGoPrev, + goNext: previewGoNext, + } = previewNavigator; + + const previewDisplay = useMemo(() => { + if (!previewDoc || !previewUrl) { + return null; + } + return { + url: previewUrl, + alt: previewDoc.title, + canGoPrev: Boolean(previewCanGoPrev), + canGoNext: Boolean(previewCanGoNext), + goPrev: previewGoPrev, + goNext: previewGoNext, + }; + }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); + + const closePreviewOverlay = useCallback(() => { + setPreviewDocId(null); + }, []); + + const handleDocumentPreviewZoom = useCallback( + (doc) => { + if (!doc || !doc.id) { + return; + } + const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; + if (!previewAsset) { + return; + } + setPreviewDocId(doc.id); + }, + [getDocumentAsset], + ); + + const handleDocumentActivate = useCallback( + (doc, event) => { + if (!doc) { + return; + } + if (event) { + if (typeof event.preventDefault === 'function') { + event.preventDefault(); + } + if (typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + } + if (event?.altKey) { + handleDocumentPreviewZoom(doc); + return; + } + onInspectDocument?.(doc.id, event); + }, + [handleDocumentPreviewZoom, onInspectDocument], + ); + + const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); + const navigableRows = useMemo( + () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), + [entries], + ); + const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); + + const getEntryByKey = useCallback( + (rowKey) => entries.find((entry) => entry.key === rowKey) || null, + [entries], + ); + + const handlePanelFocus = useCallback(() => { + let resolvedKey = null; + + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + resolvedKey = focusedRowKey; + } + + if (!resolvedKey) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + resolvedKey = candidate; + break; + } + } + } + + if (!resolvedKey && navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + + if (!resolvedKey) { + return; + } + + onFocusedRowChange?.(resolvedKey); + + if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { + onEntrySelection(resolvedKey, { + shiftKey: false, + preventDefault: () => {}, + }); + } + }, [ + focusedRowKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + selectedEntries, + selectedRowKeySet, + ]); + + const handlePanelKeyDown = 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) { + if (selectedEntries.length) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + activeKey = candidate; + break; + } + } + } + + if (!activeKey) { + activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; + } + } + + const currentIndex = navigableRowKeys.indexOf(activeKey); + const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + if (activeRow) { + onEntrySelection?.(activeRow.key, event); + if (activeRow.type === EntryType.folder) { + onFolderSelect?.(activeRow.id); + } else { + const entry = getEntryByKey(activeRow.key); + if (entry?.document) { + handleDocumentPreviewZoom(entry.document); + } + } + } + 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; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + onFocusedRowChange?.(targetRow.key); + onEntrySelection?.(targetRow.key, { + shiftKey, + preventDefault: () => {}, + }); + }, + [ + focusedRowKey, + getEntryByKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + onFolderSelect, + selectedEntries, + handleDocumentPreviewZoom, + ], + ); + + const isListView = viewMode === 'list'; + const gridIconSize = DEFAULT_GRID_ICON_SIZE; + const handleSetViewMode = useCallback( + (nextMode) => { + if (!onViewModeChange) { + return; + } + onViewModeChange(nextMode); + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, + [onViewModeChange], + ); + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [viewMode]); + const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); + const ensureFocusedRowVisible = useCallback(() => { + if (!focusedRowKey) return; + const container = scrollRef.current; + if (!container) return; + let selector = null; + if (focusedRowKey.startsWith('document:')) { + selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; + } else if (focusedRowKey.startsWith('folder:')) { + selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + if (!selector) { + return; + } + const row = container.querySelector(selector); + if (!row || !container.contains(row)) { + return; + } + + const header = container.querySelector('thead'); + const headerHeight = header ? header.getBoundingClientRect().height : 0; + const rowTop = row.offsetTop; + const rowBottom = rowTop + row.offsetHeight; + const visibleTop = container.scrollTop + headerHeight; + const visibleBottom = container.scrollTop + container.clientHeight; + + if (rowTop < visibleTop) { + container.scrollTop = Math.max(rowTop - headerHeight, 0); + return; + } + + if (rowBottom > visibleBottom) { + const nextScrollTop = rowBottom - container.clientHeight; + container.scrollTop = Math.max(nextScrollTop, 0); + } + }, [focusedRowKey]); + + useEffect(() => { + ensureFocusedRowVisible(); + }, [ensureFocusedRowVisible]); + + const activeDescendantId = useMemo(() => { + if (!focusedRowKey) return undefined; + if (focusedRowKey.startsWith('document:')) { + return `document-row-${focusedRowKey.slice('document:'.length)}`; + } + if (focusedRowKey.startsWith('folder:')) { + return `folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + return undefined; + }, [focusedRowKey]); + + const handleDocumentTagDragOver = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDragLeave = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { + return; + } + event.currentTarget.classList.remove('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDrop = useCallback( + (event, documentId) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('tag-drop-target'); + const payload = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + if (!payload) { + return; + } + try { + const parsed = JSON.parse(payload); + if (parsed?.id && onDocumentTagDrop) { + onDocumentTagDrop(documentId, parsed); + } + } catch (error) { + console.warn('[documents] Failed to parse tag drop payload', error); + } + }, + [isTagDragEvent, onDocumentTagDrop], + ); + + const handleDocumentClick = useCallback( + (doc, event) => { + if (!doc || suppressDocumentClickRef.current) { + return; + } + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, + event, + ); + } + }, + [onEntryPointer], + ); + + const handleFolderClick = useCallback( + (folder, event) => { + if (!folder) { + return; + } + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, + event, + ); + } + + if ( + !isPointerModifierEvent(event) + && isPrimaryPointerEvent(event) + && scrollRef.current + ) { + scrollRef.current.focus({ preventScroll: true }); + onFocusedRowChange?.(`folder:${folder.id}`); + } + }, + [onEntryPointer, onFocusedRowChange], + ); + + const handleDocumentDragStartLocal = useCallback( + (event, doc) => { + suppressDocumentClickRef.current = true; + onDocumentDragStart?.(event, doc); + }, + [onDocumentDragStart], + ); + + const handleDocumentDragEndLocal = useCallback( + (event) => { + onDocumentDragEnd?.(event); + requestAnimationFrame(() => { + suppressDocumentClickRef.current = false; + }); + }, + [onDocumentDragEnd], + ); + + const hasDocumentEntries = useMemo( + () => entries.some((entry) => entry.type === EntryType.document), + [entries], + ); + const showTableRows = entries.length > 0; + const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; + const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; + const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; + const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); + const trailEntries = useMemo(() => { + if (!breadcrumbEntries.length) { + return [{ id: 'current-folder', label: currentFolderName }]; + } + const lastIndex = breadcrumbEntries.length - 1; + return breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex && onFolderSelect + ? () => onFolderSelect(crumb.id) + : null, + })); + }, [breadcrumbEntries, currentFolderName, onFolderSelect]); + + return ( + <> +
+ {showHeader ? ( +
+
+

+ +

+ {showingSearchResults && ( +
Search results
+ )} +
+
+
+ + + +
+ +
+
+ ) : null} + {showDefaultEmptyState ? ( +
+
+ Drop files anywhere or onto a folder to upload documents. +
+
+ ) : showGridSearchEmptyState ? ( +
+
+ No documents match the current filters. +
+
+ ) : showListSearchEmptyState ? ( +
+
No documents match the current filters.
+
+ ) : ( +
+
{ + if (event.target === scrollRef.current) { + handlePanelFocus(); + } + }} + onKeyDown={(event) => { + if (event.target !== scrollRef.current) { + return; + } + handlePanelKeyDown(event); + }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} + aria-activedescendant={isGridView ? undefined : activeDescendantId} + > + {isGridView ? ( + + ) : !showTableRows ? null : ( + + )} +
+
+ )} +
+ + + ); +}; + +export default DocumentsPanel; diff --git a/frontend/src/documents/panel/DocumentsToolbar.jsx b/frontend/src/documents/panel/DocumentsToolbar.jsx new file mode 100644 index 0000000..e593be0 --- /dev/null +++ b/frontend/src/documents/panel/DocumentsToolbar.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { + ViewListIcon, + ViewGridIcon, + IconFileStack, + RefreshIcon, + MinusVerticalIcon, + InfoIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, +} from '../../ui/icons'; +import SortFieldQuickMenu from './SortFieldQuickMenu'; + +export const createDocumentsTableHeaderActions = ({ + viewMode, + onViewModeChange, + onRefresh, + onShowDeskHelp = null, + sortField = 'title', + onSortFieldChange = null, + sortDirection = 'asc', + onSortDirectionToggle = null, + isFilterActive = false, + includeDescendants = true, + onToggleIncludeDescendants = null, +}) => { + const isListView = viewMode === 'list'; + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const sortDirectionIsDesc = sortDirection === 'desc'; + const sortDirectionTitle = sortDirectionIsDesc + ? 'Sorting Z → A. Click to switch to ascending.' + : 'Sorting A → Z. Click to switch to descending.'; + + const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' + ? ( + + ) + : null; + + const sortControls = typeof onSortFieldChange === 'function' + ? ( +
+ + {typeof onSortDirectionToggle === 'function' ? ( + + ) : null} +
+ ) + : null; + + return ( + <> + {isDeskView && typeof onShowDeskHelp === 'function' ? ( + <> + + + + ) : null} + {includeDescendantsToggle ? ( + <> + {includeDescendantsToggle} + + + ) : null} + {sortControls ? ( + <> + {sortControls} + + + ) : null} +
+ + + +
+ + + + ); +}; + +export default createDocumentsTableHeaderActions; diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.jsx b/frontend/src/documents/panel/SortFieldQuickMenu.jsx new file mode 100644 index 0000000..35d5631 --- /dev/null +++ b/frontend/src/documents/panel/SortFieldQuickMenu.jsx @@ -0,0 +1,64 @@ +import React, { useCallback, useMemo } from 'react'; +import QuickAddMenu from '../../ui/QuickAddMenu'; + +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +]; + +const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => { + const next = acc; + next[option.value] = option.label; + return next; +}, {}); + +const SortFieldQuickMenu = ({ sortField, onChange }) => { + const currentOption = useMemo( + () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], + [sortField], + ); + + const options = useMemo( + () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), + [], + ); + + const handleSelect = useCallback( + (value, option) => { + if (typeof onChange !== 'function') { + return; + } + const nextValue = option?.id || option?.original?.id || value; + if (nextValue) { + onChange(nextValue); + } + }, + [onChange], + ); + + const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; + + return ( + + {label} + + )} + triggerAriaLabel={`Sort by ${label}`} + triggerTitle={`Sort by ${label}`} + placeholder="Select sort field" + menuMinWidth={200} + align="start" + positionStrategy="absolute" + /> + ); +}; + +export default SortFieldQuickMenu; diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.js new file mode 100644 index 0000000..1124db1 --- /dev/null +++ b/frontend/src/documents/panel/createDocumentsSurface.js @@ -0,0 +1,119 @@ +import React from 'react'; +import DetailPanel from '../../detail/DetailPanel'; +import SelectionFloatingActions from '../SelectionFloatingActions'; +import createWorkspaceSurfaceConfig from '../workspaceHeader'; +import DocumentsPanel from './DocumentsPanel'; +import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; + +const createDocumentsSurface = ({ + tableProps, + parentBreadcrumb, + onNavigateParent, + renderSidebarToggle, + detailProps, + detailOpen = false, +}) => { + const { + currentFolderName, + breadcrumbs, + searchResults, + isFilterActive, + viewMode, + onViewModeChange, + onRefresh, + sortField, + sortDirection, + onSortFieldChange, + onSortDirectionToggle, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + onInspectDocument, + } = tableProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + + const actions = createDocumentsTableHeaderActions({ + viewMode, + onViewModeChange, + onRefresh, + sortField, + onSortFieldChange, + sortDirection, + onSortDirectionToggle, + isFilterActive, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + + return createWorkspaceSurfaceConfig({ + key: 'documents', + variant: 'documents', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); +}; + +export default createDocumentsSurface;