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 ? (
+

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);
+ }
+ }}
+ >
+
+
+
+
+
+ );
+ }
+
+ 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,
+}) => (
+
+
+
+ |
+ Name |
+ Tags |
+ Issued |
+ Actions |
+
+
+
+ {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);
+ }
+ }}
+ >
+ |
+
+
+
+ |
+
+
+ {folder.name}
+
+ |
+ — |
+
+
+ {folder.id !== 'root' && onFolderRename && (
+
+ )}
+
+
+ |
+
+ );
+ }
+
+ 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)}
+ >
+ |
+
+ |
+
+
+
+
+ {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();
+ })()}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+);
+
+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 ? (
-

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);
- }
- }}
- >
-
-
-
-
-
- );
- },
- [
- 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();
- })()}
- |
-
-
- |
-
- );
- },
- [
- 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 : (
-
-
-
- |
- Name |
- Issued |
- Actions |
-
-
-
- {entries.map((entry) =>
- entry.type === EntryType.folder
- ? renderFolderListEntry(entry)
- : renderDocumentListEntry(entry)
- )}
-
-
+
)}
{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;