diff --git a/frontend/src/documents/CorrespondentLinks.jsx b/frontend/src/documents/CorrespondentLinks.jsx new file mode 100644 index 0000000..f37c268 --- /dev/null +++ b/frontend/src/documents/CorrespondentLinks.jsx @@ -0,0 +1,60 @@ +import React from 'react'; + +const NBSP = String.fromCharCode(160); + +const CorrespondentLinks = ({ + correspondents, + activeCorrespondentIdSet, + onCorrespondentClick, +}) => { + if (!Array.isArray(correspondents) || correspondents.length === 0) { + return null; + } + + const activeSet = activeCorrespondentIdSet || new Set(); + const handleClick = (event, correspondent) => { + if (!onCorrespondentClick || correspondent.id == null) { + return; + } + event.stopPropagation(); + onCorrespondentClick(correspondent.id); + }; + + return correspondents.map((correspondent, index) => { + const isActive = correspondent.id != null && activeSet.has(correspondent.id); + const hasHandler = Boolean(onCorrespondentClick) && correspondent.id != null; + const classNames = ['doc-correspondent-link']; + if (isActive) classNames.push('is-active'); + if (!hasHandler) classNames.push('is-static'); + const isLast = index === correspondents.length - 1; + const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name; + + return ( + + + {isLast ? null : , } + + ); + }); +}; + +export default CorrespondentLinks; diff --git a/frontend/src/documents/DocumentThumbnailImage.jsx b/frontend/src/documents/DocumentThumbnailImage.jsx new file mode 100644 index 0000000..aeb038a --- /dev/null +++ b/frontend/src/documents/DocumentThumbnailImage.jsx @@ -0,0 +1,155 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; + +const DEFAULT_THUMBNAIL_SIZE = 48; + +// Detect when an element becomes visible within a scroll container so we can delay loading. +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 getPageCount = (doc) => + Number.isFinite(doc?.current_version?.metadata?.page_count) + ? doc.current_version.metadata.page_count + : null; + +const DocumentThumbnailImage = ({ + document, + ensureAssetUrl, + getDocumentAsset, + alt = '', + maxSize = DEFAULT_THUMBNAIL_SIZE, + scrollRootRef = null, +}) => { + const documentId = document?.id; + const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId); + 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
+ )} +
+
+ ); +}; + +export default DocumentThumbnailImage; +export { DEFAULT_THUMBNAIL_SIZE }; diff --git a/frontend/src/documents/DocumentsGrid.jsx b/frontend/src/documents/DocumentsGrid.jsx new file mode 100644 index 0000000..f3473d2 --- /dev/null +++ b/frontend/src/documents/DocumentsGrid.jsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { FolderIcon } from '../ui/icons'; +import DocumentThumbnailImage from './DocumentThumbnailImage'; +import CorrespondentLinks from './CorrespondentLinks'; +import { getTagColorStyle } from '../utils/colors'; +import { resolveCorrespondents } from './correspondents'; + +const DocumentsGrid = ({ + entries, + selectedDocumentIdsSet, + selectedFolderIdsSet, + draggingDocumentIdsSet, + draggedFolderId, + onFolderClick, + onFolderSelect, + onFolderDragOver, + onFolderDragLeave, + onFolderDrop, + onFolderDragStart, + onFolderDragEnd, + onDocumentClick, + onDocumentOpen, + onDocumentDragStart, + onDocumentDragEnd, + onDocumentTagDragOver, + onDocumentTagDragLeave, + onDocumentTagDrop, + ensureAssetUrl, + getDocumentAsset, + gridIconSize, + tagLookupById, + onTagClick, + scrollRef, + onCorrespondentClick, + activeCorrespondentIdSet, + onClearSelection, +}) => ( +
{ + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} + > + {entries.map((entry) => { + if (entry.type === 'folder') { + const folder = entry.folder; + if (!folder) { + return null; + } + const canDragFolder = folder.id !== 'root'; + const isDraggingFolder = draggedFolderId === folder.id; + const isSelectedFolder = selectedFolderIdsSet?.has(folder.id); + const classes = ['document-card', 'folder-card']; + if (isDraggingFolder) classes.push('is-dragging'); + if (isSelectedFolder) classes.push('selected'); + + return ( +
onFolderClick?.(folder, event)} + 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} +
+
+
+ ); + } + + const doc = entry.document; + if (!doc) { + return null; + } + + const isSelected = selectedDocumentIdsSet?.has(doc.id); + const isDraggingDoc = draggingDocumentIdsSet?.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 ( +
onDocumentClick?.(doc, event)} + onDoubleClick={() => onDocumentOpen?.(doc.id)} + draggable + onDragStart={(event) => onDocumentDragStart?.(event, doc)} + onDragEnd={(event) => onDocumentDragEnd?.(event)} + onDragOver={(event) => onDocumentTagDragOver?.(event)} + onDragOverCapture={(event) => onDocumentTagDragOver?.(event)} + onDragLeave={onDocumentTagDragLeave} + onDragLeaveCapture={onDocumentTagDragLeave} + onDrop={(event) => onDocumentTagDrop?.(event, doc.id)} + onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)} + > + +
+
+ {correspondents.length > 0 ? ( + + + + ) : null} + {titleText} +
+ {visibleTags.length > 0 && ( +
+ {visibleTags.map((tag) => { + const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; + const style = getTagColorStyle(colorSource); + return ( + { + event.stopPropagation(); + onTagClick?.(tag.id); + }} + 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(); + onTagClick?.(tag.id); + } + }} + > + {tag.label} + + ); + })} + {remainingTagCount > 0 && ( + +{remainingTagCount} + )} +
+ )} +
+
+ ); + })} +
+); + +export default DocumentsGrid; diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.jsx new file mode 100644 index 0000000..686da7c --- /dev/null +++ b/frontend/src/documents/DocumentsList.jsx @@ -0,0 +1,331 @@ +import React from 'react'; +import { + FolderIcon, + EditIcon, + DownloadIcon, + TrashIcon, +} from '../ui/icons'; +import { getTagColorStyle } from '../utils/colors'; +import DocumentThumbnailImage from './DocumentThumbnailImage'; +import CorrespondentLinks from './CorrespondentLinks'; +import { resolveCorrespondents } from './correspondents'; + +const DocumentsList = ({ + entries, + focusedRowKey, + selectedDocumentIdsSet, + selectedFolderIdsSet, + draggingDocumentIdsSet, + draggedFolderId, + ensureAssetUrl, + getDocumentAsset, + getDownloadHref, + onFolderClick, + onFolderSelect, + onFolderDragOver, + onFolderDragLeave, + onFolderDrop, + onFolderDragStart, + onFolderDragEnd, + onFolderRename, + onFolderDelete, + onDocumentClick, + onDocumentOpen, + onDocumentDragStart, + onDocumentDragEnd, + onDocumentTagDragOver, + onDocumentTagDragLeave, + onDocumentTagDrop, + onDocumentRename, + onDocumentDelete, + tagLookupById, + onTagClick, + onCorrespondentClick, + activeCorrespondentIdSet, + scrollRef, +}) => ( + + + + + + + + + + + + {entries.map((entry) => { + if (entry.type === 'folder') { + const folder = entry.folder; + if (!folder) { + return null; + } + const canDragFolder = folder.id !== 'root'; + const isDraggingFolder = draggedFolderId === folder.id; + const isSelectedFolder = selectedFolderIdsSet?.has(folder.id); + const rowKey = `folder:${folder.id}`; + + return ( + onFolderClick?.(folder, event)} + 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); + } + }} + > + + + + + + ); + } + + const doc = entry.document; + if (!doc) { + return null; + } + const isSelected = selectedDocumentIdsSet?.has(doc.id); + const isDraggingDoc = draggingDocumentIdsSet?.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 ( + onDocumentClick?.(doc, event)} + onDoubleClick={() => onDocumentOpen?.(doc.id)} + draggable + onDragStart={(event) => onDocumentDragStart?.(event, doc)} + onDragEnd={(event) => onDocumentDragEnd?.(event)} + onDragOver={onDocumentTagDragOver} + onDragLeave={onDocumentTagDragLeave} + onDrop={(event) => onDocumentTagDrop?.(event, doc.id)} + > + + + + + + ); + })} + +
NameTagsIssuedActions
+
+ +
+
+
+ {folder.name} +
+
+
+ {folder.id !== 'root' && onFolderRename && ( + + )} + +
+
+ + +
+
+ + {correspondents.length > 0 ? ( + + + + ) : 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(); + onTagClick?.(tag.id); + }} + 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(); + 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 + )} + +
+
+); + +export default DocumentsList; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index 7ef772b..804a64d 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -1,206 +1,25 @@ 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'; +import DocumentsGrid from './DocumentsGrid'; +import DocumentsList from './DocumentsList'; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const DEFAULT_GRID_ICON_SIZE = 144; -const LIST_ICON_SIZE = 48; const EntryType = { folder: 'folder', document: 'document', }; -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 DocumentsPanel = ({ currentFolderName, breadcrumbs, @@ -498,571 +317,6 @@ const DocumentsPanel = ({ [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 renderFolderGridEntry = useCallback( - (entry) => { - const folder = entry.folder; - if (!folder) { - return null; - } - 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 ( -
handleFolderClick(folder, event)} - 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} -
-
-
- ); - }, - [ - draggedFolderId, - gridIconSize, - handleFolderClick, - onFolderDragEnd, - onFolderDragLeave, - onFolderDragOver, - onFolderDragStart, - onFolderDrop, - onFolderSelect, - selectedFolderSet, - ], - ); - - const renderDocumentGridEntry = useCallback( - (entry) => { - const doc = entry.document; - if (!doc) { - return null; - } - 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, 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} - )} -
- )} -
-
- ); - }, - [ - draggingSet, - ensureAssetUrl, - getDocumentAsset, - gridIconSize, - handleDocumentClick, - handleDocumentDragEndLocal, - handleDocumentDragStartLocal, - handleDocumentTagDragLeave, - handleDocumentTagDragOver, - handleDocumentTagDrop, - onDocumentOpen, - onTagClick, - renderCorrespondentLinks, - scrollRef, - selectedSet, - tagLookupById, - ], - ); - - const renderFolderListEntry = useCallback( - (entry) => { - const folder = entry.folder; - if (!folder) { - return null; - } - const canDragFolder = folder.id !== 'root'; - const isDraggingFolder = draggedFolderId === folder.id; - const isSelectedFolder = selectedFolderSet.has(folder.id); - const rowKey = `folder:${folder.id}`; - - return ( - handleFolderClick(folder, event)} - 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); - } - }} - > - -
- -
- - -
- {folder.name} -
- - — - -
- {folder.id !== 'root' && onFolderRename && ( - - )} - -
- - - ); - }, - [ - draggedFolderId, - focusedRowKey, - handleFolderClick, - onFolderDelete, - onFolderDragEnd, - onFolderDragLeave, - onFolderDragOver, - onFolderDragStart, - onFolderDrop, - onFolderRename, - onFolderSelect, - selectedFolderSet, - ], - ); - - const renderDocumentListEntry = useCallback( - (entry) => { - const doc = entry.document; - if (!doc) { - return null; - } - 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, event)} - onDoubleClick={() => onDocumentOpen(doc.id)} - draggable - onDragStart={(event) => handleDocumentDragStartLocal(event, doc)} - onDragEnd={handleDocumentDragEndLocal} - onDragOver={handleDocumentTagDragOver} - onDragLeave={handleDocumentTagDragLeave} - onDrop={(event) => handleDocumentTagDrop(event, doc.id)} - > - - - - -
-
- - {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 - )} - -
- - - ); - }, - [ - draggingSet, - ensureAssetUrl, - getDocumentAsset, - getDownloadHref, - handleDocumentClick, - handleDocumentDragEndLocal, - handleDocumentDragStartLocal, - handleDocumentTagDragLeave, - handleDocumentTagDragOver, - handleDocumentTagDrop, - onDocumentDelete, - onDocumentOpen, - onDocumentRename, - onTagClick, - renderCorrespondentLinks, - scrollRef, - selectedSet, - tagLookupById, - ], - ); - const hasDocumentEntries = useMemo( () => entries.some((entry) => entry.type === EntryType.document), [entries], @@ -1184,40 +438,71 @@ const DocumentsPanel = ({ aria-activedescendant={isGridView ? undefined : activeDescendantId} > {isGridView ? ( -
{ - if (event.target === event.currentTarget) { - onClearSelection?.(); - } - }} - style={{ '--documents-grid-icon-size': `${gridIconSize}px` }} - > - {entries.map((entry) => - entry.type === EntryType.folder - ? renderFolderGridEntry(entry) - : renderDocumentGridEntry(entry) - )} -
+ ) : !showTableRows ? null : ( - - - - - - - - - - - {entries.map((entry) => - entry.type === EntryType.folder - ? renderFolderListEntry(entry) - : renderDocumentListEntry(entry) - )} - -
NameIssuedActions
+ )} {showSearchHint && ( @@ -1232,7 +517,6 @@ const DocumentsPanel = ({ }; export default DocumentsPanel; -export { DocumentThumbnailImage }; export const createDocumentsTableHeaderActions = ({ viewMode, diff --git a/frontend/src/documents/correspondents.js b/frontend/src/documents/correspondents.js new file mode 100644 index 0000000..60e4ab6 --- /dev/null +++ b/frontend/src/documents/correspondents.js @@ -0,0 +1,38 @@ +export 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; +}; + +export default resolveCorrespondents;