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 (event) { if (typeof event.preventDefault === 'function') { event.preventDefault(); } if (typeof event.stopPropagation === 'function') { event.stopPropagation(); } } handleDocumentPreviewZoom(doc); }, [handleDocumentPreviewZoom], ); 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) { handleDocumentActivate(entry.document, event); onInspectDocument?.(entry.document.id, event); } } } 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, handleDocumentActivate, navigableRowKeys, navigableRows, onEntrySelection, onFocusedRowChange, onFolderSelect, onInspectDocument, selectedEntries, ], ); 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, ); } if ( typeof onInspectDocument === 'function' && !isPointerModifierEvent(event) && isPrimaryPointerEvent(event) ) { onInspectDocument(doc.id, event); } }, [onEntryPointer, onInspectDocument], ); 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, } = 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; };