import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { getTagColorStyle } from '../utils/colors'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, IconFileStack, FolderIcon, TrashIcon, RefreshIcon, MinusVerticalIcon, } from '../ui/icons'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import createWorkspaceSurfaceConfig from './workspaceHeader'; import DetailPanel from '../detail/DetailPanel'; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const DEFAULT_GRID_ICON_SIZE = 144; const LIST_ICON_SIZE = 48; const getPageCount = (doc) => Number.isFinite(doc?.current_version?.metadata?.page_count) ? doc.current_version.metadata.page_count : null; const resolveCorrespondents = (doc) => { if (!doc || !Array.isArray(doc.correspondents)) { return []; } const seen = new Set(); const results = []; doc.correspondents.forEach((entry, index) => { if (!entry || typeof entry.name !== 'string') return; const id = entry.id; const name = entry.name.trim(); if (!name) return; if (id && seen.has(id)) { return; } if (id) { seen.add(id); } results.push({ id, name, key: id ?? `${name}-${index}`, }); }); return results; }; // Detects when an element becomes visible within a scroll container. const useLazyVisibility = (rootRef, resetKey) => { const targetRef = useRef(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { setIsVisible(false); }, [resetKey]); const rootNode = rootRef?.current || null; useEffect(() => { if (isVisible) { return undefined; } const element = targetRef.current; if (!element) { return undefined; } if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') { setIsVisible(true); return undefined; } const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { setIsVisible(true); observer.disconnect(); } }); }, { root: rootNode, rootMargin: '200px 0px', threshold: 0.01, }, ); observer.observe(element); return () => observer.disconnect(); }, [isVisible, rootNode, resetKey]); return { ref: targetRef, isVisible }; }; const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt = '', maxSize = LIST_ICON_SIZE, scrollRootRef = null, }) => { const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const thumbnailAsset = useMemo( () => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version], ); const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]); const primaryMetadata = thumbnailView.getPrimaryMetadata() || {}; const assetWidth = Number(primaryMetadata?.width); const assetHeight = Number(primaryMetadata?.height); const dimensions = useMemo(() => { if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) { return { width: resolvedMaxSize, height: resolvedMaxSize }; } const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight); return { width: Math.max(1, Math.round(assetWidth * scale)), height: Math.max(1, Math.round(assetHeight * scale)), }; }, [assetWidth, assetHeight, resolvedMaxSize]); const innerStyle = useMemo( () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), [dimensions.height, dimensions.width], ); const url = useMemo(() => { if (!isVisible) { return null; } return resolveDocumentAssetUrl(document, 'thumbnail', { ensureAssetUrl, getAsset: getDocumentAsset, }); }, [document, ensureAssetUrl, getDocumentAsset, isVisible]); const pageCount = getPageCount(document); const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; const innerClasses = ['document-thumbnail-inner']; if (showMultiPageBadge) { innerClasses.push('document-thumbnail-inner--multipage'); } const aspectRatio = useMemo(() => { if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) { return assetWidth / assetHeight; } return null; }, [assetWidth, assetHeight]); useEffect(() => { const node = visibilityRef.current; if (!node) { return; } if (aspectRatio) { node.dataset.thumbnailAspect = String(aspectRatio); } else { delete node.dataset.thumbnailAspect; } }, [aspectRatio, visibilityRef]); return (
{url ? ( {alt} event.preventDefault()} /> ) : (
DOC
)}
); }; const DocumentsTable = ({ currentFolderName, breadcrumbs, onRefresh, subfolders, documents, searchResults, isFilterActive = false, onFolderSelect, onFolderDrop, onFolderDragOver, onFolderDragLeave, onFolderDragStart, onFolderDragEnd, draggedFolderId, onFolderDelete, selectedFolderIds = [], onFolderRowClick, onDocumentRowClick, onDocumentOpen, selectedDocumentIds, focusedRowKey, draggingDocumentIds = [], onDocumentDragStart, onDocumentDragEnd, onDocumentDelete, onFolderRename, onDocumentRename, tagLookupById, activeCorrespondentIds = [], onDocumentListFocus, onDocumentListKeyDown, onFocusedRowChange, ensureAssetUrl = null, getDocumentAsset = () => null, getDownloadHref, onTagClick, onCorrespondentClick, isSearchLoading = false, onDocumentTagDrop, viewMode = 'list', onViewModeChange, onClearSelection, showHeader = true, }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; 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 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) => { const types = Array.from(event.dataTransfer?.types || []); return TAG_MIME_TYPES.some((type) => types.includes(type)); }, []); 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( (documentId, event) => { if (suppressDocumentClickRef.current) { return; } onDocumentRowClick?.(documentId, event); }, [onDocumentRowClick], ); const handleDocumentDragStartLocal = useCallback( (event, doc) => { suppressDocumentClickRef.current = true; onDocumentDragStart?.(event, doc); }, [onDocumentDragStart], ); const handleDocumentDragEndLocal = useCallback( (event) => { onDocumentDragEnd?.(event); requestAnimationFrame(() => { suppressDocumentClickRef.current = false; }); }, [onDocumentDragEnd], ); const renderCorrespondentLinks = useCallback( (correspondents) => correspondents.map((correspondent, index) => { const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id); const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null; const classNames = ['doc-correspondent-link']; if (isActive) classNames.push('is-active'); if (!hasClickHandler) classNames.push('is-static'); const isLast = index === correspondents.length - 1; const label = isLast ? `${correspondent.name}:${String.fromCharCode(160)}` : correspondent.name; return ( {!isLast ? ( , ) : null} ); }), [activeCorrespondentIdSet, onCorrespondentClick], ); const noDocumentRows = rows.length === 0; const hasFolderRows = !showingSearchResults && subfolders.length > 0; const showTableRows = hasFolderRows || !noDocumentRows; const showDefaultEmptyState = !showingSearchResults && !isFilterActive && !hasFolderRows && noDocumentRows; const showListSearchEmptyState = showingSearchResults && noDocumentRows && !isGridView && !isSearchLoading; const showGridSearchEmptyState = isGridView && showingSearchResults && noDocumentRows && !isSearchLoading; const showSearchHint = showingSearchResults && rows.length > 0; 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) { onDocumentListFocus?.(); } }} onKeyDown={(event) => { if (event.target !== scrollRef.current) { return; } if (onDocumentListKeyDown) { onDocumentListKeyDown(event); } }} onClick={(event) => { if (event.target === event.currentTarget) { onClearSelection?.(); } }} aria-activedescendant={isGridView ? undefined : activeDescendantId} > {isGridView ? (
{ if (event.target === event.currentTarget) { onClearSelection?.(); } }} style={{ '--documents-grid-icon-size': `${gridIconSize}px` }} > {!showingSearchResults && subfolders.map((folder) => { const canDragFolder = folder.id !== 'root'; const isDraggingFolder = draggedFolderId === folder.id; const isSelectedFolder = selectedFolderSet.has(folder.id); const classes = ['document-card', 'folder-card']; if (isDraggingFolder) classes.push('is-dragging'); if (isSelectedFolder) classes.push('selected'); return (
{ onFolderRowClick?.(folder.id, event); const shouldNavigate = !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.shiftKey; if (shouldNavigate) { onFolderSelect(folder.id); } }} onDoubleClick={(event) => { event.preventDefault(); onFolderSelect(folder.id); }} onDragOver={(event) => onFolderDragOver(event, folder.id)} onDragLeave={onFolderDragLeave} onDrop={(event) => onFolderDrop(event, folder.id)} onDragStart={(event) => { if (canDragFolder) { onFolderDragStart(event, folder.id); } }} onDragEnd={(event) => { if (canDragFolder) { onFolderDragEnd(event); } }} >
{folder.name}
); })} {rows.map((doc) => { const isSelected = selectedSet.has(doc.id); const isDraggingDoc = draggingSet.has(doc.id); const tagList = Array.isArray(doc.tags) ? doc.tags : []; const visibleTags = tagList.slice(0, 3); const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0; const correspondents = resolveCorrespondents(doc); const cardClasses = ['document-card', 'document']; if (isSelected) cardClasses.push('selected'); if (isDraggingDoc) cardClasses.push('is-dragging'); const titleText = doc.title || doc.original_name; return (
handleDocumentClick(doc.id, event)} onDoubleClick={() => onDocumentOpen(doc.id)} draggable onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} onDragEnd={handleDocumentDragEndLocal} onDragOver={(event) => handleDocumentTagDragOver(event)} onDragOverCapture={(event) => handleDocumentTagDragOver(event)} onDragLeave={handleDocumentTagDragLeave} onDragLeaveCapture={handleDocumentTagDragLeave} onDrop={(event) => handleDocumentTagDrop(event, doc.id)} onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)} >
{correspondents.length > 0 ? ( {renderCorrespondentLinks(correspondents)} ) : null} {titleText}
{visibleTags.length > 0 && (
{visibleTags.map((tag) => { const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; const style = getTagColorStyle(colorSource); return ( { event.stopPropagation(); if (onTagClick) { onTagClick(tag.id); } }} role="button" draggable onDragStart={(event) => { event.stopPropagation(); try { if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'copyMove'; } const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id, }); event.dataTransfer?.setData('application/x-papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); } catch (error) { console.warn('[documents] Failed to configure tag drag payload', error); } }} onDragEnd={(event) => { event.stopPropagation(); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); if (onTagClick) { onTagClick(tag.id); } } }} > {tag.label} ); })} {remainingTagCount > 0 && ( +{remainingTagCount} )}
)}
); })}
) : !showTableRows ? null : ( {!showingSearchResults && subfolders.map((folder) => { const canDragFolder = folder.id !== 'root'; const isDraggingFolder = draggedFolderId === folder.id; const isSelectedFolder = selectedFolderSet.has(folder.id); return ( { onFolderRowClick?.(folder.id, event); const shouldNavigate = !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.shiftKey; if (shouldNavigate) { onFolderSelect(folder.id); } if (scrollRef.current) { scrollRef.current.focus({ preventScroll: true }); } onFocusedRowChange?.(`folder:${folder.id}`); }} onDoubleClick={(event) => { event.preventDefault(); onFolderSelect(folder.id); }} onDragOver={(event) => onFolderDragOver(event, folder.id)} onDragLeave={onFolderDragLeave} onDrop={(event) => onFolderDrop(event, folder.id)} draggable={canDragFolder} onDragStart={(event) => { if (canDragFolder) { onFolderDragStart(event, folder.id); } }} onDragEnd={(event) => { if (canDragFolder) { onFolderDragEnd(event); } }} > ); })} {rows.map((doc) => { const isSelected = selectedSet.has(doc.id); const isDraggingDoc = draggingSet.has(doc.id); const rowClasses = ['document']; if (isSelected) rowClasses.push('selected'); if (isDraggingDoc) rowClasses.push('is-dragging'); const downloadHref = getDownloadHref?.(doc) || null; const correspondents = resolveCorrespondents(doc); const titleText = doc.title || doc.original_name; return ( handleDocumentClick(doc.id, event)} onDoubleClick={() => onDocumentOpen(doc.id)} draggable onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} onDragEnd={handleDocumentDragEndLocal} onDragOver={handleDocumentTagDragOver} onDragLeave={handleDocumentTagDragLeave} onDrop={(event) => handleDocumentTagDrop(event, doc.id)} > ); })}
Preview Name Issued Actions
{folder.name}
{folder.id !== 'root' && onFolderRename && ( )}
{correspondents.length > 0 ? ( {renderCorrespondentLinks(correspondents)} ) : null} {titleText}
{(doc.tags || []).length > 0 && (
{(doc.tags || []).map((tag) => { const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; const style = getTagColorStyle(colorSource); return ( { event.stopPropagation(); if (onTagClick) { onTagClick(tag.id); } }} role="button" draggable onDragStart={(event) => { event.stopPropagation(); try { if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'copyMove'; } const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id, }); event.dataTransfer?.setData('application/x-papercrate-tag', payload); event.dataTransfer?.setData('text/papercrate-tag', payload); event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); } catch (error) { console.warn('[documents] Failed to configure tag drag payload', error); } }} onDragEnd={(event) => { event.stopPropagation(); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); if (onTagClick) { onTagClick(tag.id); } } }} > {tag.label} ); })}
)}
{(() => { const issuedAt = doc.issued_at || null; if (!issuedAt) { return '—'; } const timestamp = Date.parse(issuedAt); if (Number.isNaN(timestamp)) { return '—'; } return new Date(timestamp).toLocaleDateString(); })()}
{onDocumentRename && ( )} {downloadHref ? ( event.stopPropagation()} onAuxClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} > ) : ( No download )}
)}
{showSearchHint && (
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
)}
)}
); }; export default DocumentsTable; export { DocumentThumbnailImage }; export const createDocumentsTableHeaderActions = ({ viewMode, onViewModeChange, onRefresh, }) => { const isListView = viewMode === 'list'; const isGridView = viewMode === 'grid'; const isDeskView = viewMode === 'desk'; return ( <>
); }; export const createDocumentsSurface = ({ tableProps, parentBreadcrumb, onNavigateParent, renderSidebarToggle, detailProps, detailOpen = false, }) => { const { currentFolderName, breadcrumbs, searchResults, viewMode, onViewModeChange, onRefresh, } = tableProps; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; const subtitle = Array.isArray(searchResults) ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` : null; const actions = createDocumentsTableHeaderActions({ viewMode, onViewModeChange, onRefresh, }); const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const detail = detailOpen && detailProps ? : null; const surfaceConfig = createWorkspaceSurfaceConfig({ key: 'documents', variant: 'documents', title, subtitle, sidebarToggle, parentBreadcrumb, onNavigateParent, actions, breadcrumbs, content: , detail, }); return surfaceConfig; };