refactor
This commit is contained in:
@@ -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 (
|
||||||
|
<React.Fragment
|
||||||
|
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames.join(' ')}
|
||||||
|
aria-disabled={hasHandler ? undefined : true}
|
||||||
|
onClick={(event) => handleClick(event, correspondent)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!hasHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleClick(event, correspondent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{isLast ? null : <span className="doc-correspondent-link__separator">, </span>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CorrespondentLinks;
|
||||||
@@ -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 (
|
||||||
|
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||||
|
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||||
|
{url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className="document-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="thumb-placeholder">DOC</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentThumbnailImage;
|
||||||
|
export { DEFAULT_THUMBNAIL_SIZE };
|
||||||
@@ -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,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className="documents-grid"
|
||||||
|
role="list"
|
||||||
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className={classes.join(' ')}
|
||||||
|
role="listitem"
|
||||||
|
id={`folder-card-${folder.id}`}
|
||||||
|
draggable={canDragFolder}
|
||||||
|
onClick={(event) => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="folder-card__icon">
|
||||||
|
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||||
|
</div>
|
||||||
|
<div className="folder-card__meta">
|
||||||
|
<div className="folder-card__name" title={folder.name}>
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className={cardClasses.join(' ')}
|
||||||
|
role="listitem"
|
||||||
|
id={`document-card-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
|
onClick={(event) => 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)}
|
||||||
|
>
|
||||||
|
<DocumentThumbnailImage
|
||||||
|
document={doc}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
alt={`Thumbnail for ${titleText}`}
|
||||||
|
maxSize={gridIconSize}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
|
/>
|
||||||
|
<div className="document-card__meta">
|
||||||
|
<div className="document-card__title" title={titleText}>
|
||||||
|
{correspondents.length > 0 ? (
|
||||||
|
<span className="doc-correspondents">
|
||||||
|
<CorrespondentLinks
|
||||||
|
correspondents={correspondents}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="doc-name__primary">{titleText}</span>
|
||||||
|
</div>
|
||||||
|
{visibleTags.length > 0 && (
|
||||||
|
<div className="document-card__tags">
|
||||||
|
{visibleTags.map((tag) => {
|
||||||
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
|
const style = getTagColorStyle(colorSource);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="badge tag-chip"
|
||||||
|
style={style || undefined}
|
||||||
|
title={tag.label}
|
||||||
|
role="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{remainingTagCount > 0 && (
|
||||||
|
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsGrid;
|
||||||
@@ -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,
|
||||||
|
}) => (
|
||||||
|
<table aria-multiselectable="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="thumb-column"></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Tags</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr
|
||||||
|
key={entry.key}
|
||||||
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||||||
|
focusedRowKey === rowKey ? ' focused' : ''
|
||||||
|
}${isSelectedFolder ? ' selected' : ''}`}
|
||||||
|
id={`folder-row-${folder.id}`}
|
||||||
|
onClick={(event) => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="thumb-cell">
|
||||||
|
<div className="thumb-icon">
|
||||||
|
<FolderIcon className="thumb-icon__image" size={32} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="doc-list__name">
|
||||||
|
<div className="doc-list__name-content">
|
||||||
|
<span>{folder.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td className="actions">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{folder.id !== 'root' && onFolderRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename folder ${folder.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextName = window.prompt('Rename folder', folder.name);
|
||||||
|
if (!nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || trimmed === folder.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFolderRename?.(folder.id, trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label={`Delete folder ${folder.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onFolderDelete?.(folder.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr
|
||||||
|
key={entry.key}
|
||||||
|
className={rowClasses.join(' ')}
|
||||||
|
id={`document-row-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
|
onClick={(event) => 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)}
|
||||||
|
>
|
||||||
|
<td className="thumb-cell">
|
||||||
|
<DocumentThumbnailImage
|
||||||
|
document={doc}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
alt={`Thumbnail for ${titleText}`}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="doc-list__name">
|
||||||
|
<div className="doc-name">
|
||||||
|
<div className="doc-list__name-content">
|
||||||
|
<span className="doc-name__title">
|
||||||
|
{correspondents.length > 0 ? (
|
||||||
|
<span className="doc-correspondents">
|
||||||
|
<CorrespondentLinks
|
||||||
|
correspondents={correspondents}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="doc-name__primary">{titleText}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(doc.tags || []).length > 0 && (
|
||||||
|
<div className="doc-name__tags">
|
||||||
|
{(doc.tags || []).map((tag) => {
|
||||||
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
|
const style = getTagColorStyle(colorSource);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="badge tag-chip"
|
||||||
|
style={style || undefined}
|
||||||
|
title={tag.label}
|
||||||
|
role="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{(() => {
|
||||||
|
const issuedAt = doc.issued_at || null;
|
||||||
|
if (!issuedAt) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(issuedAt);
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td className="actions">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{onDocumentRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename document ${titleText}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextName = window.prompt('Rename document', doc.title);
|
||||||
|
if (!nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || trimmed === doc.title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDocumentRename?.(doc.id, trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{downloadHref ? (
|
||||||
|
<a
|
||||||
|
className="icon-button"
|
||||||
|
href={downloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download document"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onAuxClick={(event) => event.stopPropagation()}
|
||||||
|
onContextMenu={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="icon-inline" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="meta">No download</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete document"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDocumentDelete?.(doc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsList;
|
||||||
@@ -1,206 +1,25 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
|
||||||
EditIcon,
|
|
||||||
ViewListIcon,
|
ViewListIcon,
|
||||||
ViewGridIcon,
|
ViewGridIcon,
|
||||||
IconFileStack,
|
IconFileStack,
|
||||||
FolderIcon,
|
|
||||||
TrashIcon,
|
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||||
import DetailPanel from '../detail/DetailPanel';
|
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 TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
const LIST_ICON_SIZE = 48;
|
|
||||||
|
|
||||||
const EntryType = {
|
const EntryType = {
|
||||||
folder: 'folder',
|
folder: 'folder',
|
||||||
document: 'document',
|
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 (
|
|
||||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
|
||||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
|
||||||
{url ? (
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={alt}
|
|
||||||
className="document-thumbnail"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
onDragStart={(event) => event.preventDefault()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="thumb-placeholder">DOC</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentsPanel = ({
|
const DocumentsPanel = ({
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
@@ -498,571 +317,6 @@ const DocumentsPanel = ({
|
|||||||
[onDocumentDragEnd],
|
[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 (
|
|
||||||
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames.join(' ')}
|
|
||||||
aria-disabled={hasClickHandler ? undefined : true}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!hasClickHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.stopPropagation();
|
|
||||||
onCorrespondentClick(correspondent.id, correspondent);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (!hasClickHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === ' ' || event.key === 'Enter') {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{!isLast ? (
|
|
||||||
<span className="doc-correspondent-link__separator">, </span>
|
|
||||||
) : null}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[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 (
|
|
||||||
<div
|
|
||||||
key={entry.key}
|
|
||||||
className={classes.join(' ')}
|
|
||||||
role="listitem"
|
|
||||||
id={`folder-card-${folder.id}`}
|
|
||||||
draggable={canDragFolder}
|
|
||||||
onClick={(event) => 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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="folder-card__icon">
|
|
||||||
<FolderIcon
|
|
||||||
className="folder-card__icon-svg"
|
|
||||||
size={gridIconSize}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="folder-card__meta">
|
|
||||||
<div className="folder-card__name" title={folder.name}>
|
|
||||||
{folder.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
key={entry.key}
|
|
||||||
className={cardClasses.join(' ')}
|
|
||||||
role="listitem"
|
|
||||||
id={`document-card-${doc.id}`}
|
|
||||||
data-doc-id={doc.id}
|
|
||||||
onClick={(event) => 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)}
|
|
||||||
>
|
|
||||||
<DocumentThumbnailImage
|
|
||||||
document={doc}
|
|
||||||
ensureAssetUrl={ensureAssetUrl}
|
|
||||||
getDocumentAsset={getDocumentAsset}
|
|
||||||
alt={`Thumbnail for ${titleText}`}
|
|
||||||
maxSize={gridIconSize}
|
|
||||||
scrollRootRef={scrollRef}
|
|
||||||
/>
|
|
||||||
<div className="document-card__meta">
|
|
||||||
<div
|
|
||||||
className="document-card__title"
|
|
||||||
title={titleText}
|
|
||||||
>
|
|
||||||
{correspondents.length > 0 ? (
|
|
||||||
<span className="doc-correspondents">
|
|
||||||
{renderCorrespondentLinks(correspondents)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="doc-name__primary">{titleText}</span>
|
|
||||||
</div>
|
|
||||||
{visibleTags.length > 0 && (
|
|
||||||
<div className="document-card__tags">
|
|
||||||
{visibleTags.map((tag) => {
|
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
|
||||||
const style = getTagColorStyle(colorSource);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={tag.id}
|
|
||||||
className="badge tag-chip"
|
|
||||||
style={style || undefined}
|
|
||||||
title={tag.label}
|
|
||||||
onClick={(event) => {
|
|
||||||
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}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{remainingTagCount > 0 && (
|
|
||||||
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
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 (
|
|
||||||
<tr
|
|
||||||
key={entry.key}
|
|
||||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
|
||||||
focusedRowKey === rowKey ? ' focused' : ''
|
|
||||||
}${isSelectedFolder ? ' selected' : ''}`}
|
|
||||||
id={`folder-row-${folder.id}`}
|
|
||||||
onClick={(event) => 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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="thumb-cell">
|
|
||||||
<div className="thumb-icon">
|
|
||||||
<FolderIcon
|
|
||||||
className="thumb-icon__image"
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="doc-list__name">
|
|
||||||
<div className="doc-list__name-content">
|
|
||||||
<span>{folder.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>—</td>
|
|
||||||
<td className="actions">
|
|
||||||
<div className="action-buttons">
|
|
||||||
{folder.id !== 'root' && onFolderRename && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
title="Rename"
|
|
||||||
aria-label={`Rename folder ${folder.name}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const nextName = window.prompt('Rename folder', folder.name);
|
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === folder.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onFolderRename(folder.id, trimmed);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger"
|
|
||||||
title="Delete"
|
|
||||||
aria-label={`Delete folder ${folder.name}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onFolderDelete(folder.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
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 (
|
|
||||||
<tr
|
|
||||||
key={entry.key}
|
|
||||||
className={rowClasses.join(' ')}
|
|
||||||
id={`document-row-${doc.id}`}
|
|
||||||
data-doc-id={doc.id}
|
|
||||||
onClick={(event) => 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)}
|
|
||||||
>
|
|
||||||
<td className="thumb-cell">
|
|
||||||
<DocumentThumbnailImage
|
|
||||||
document={doc}
|
|
||||||
ensureAssetUrl={ensureAssetUrl}
|
|
||||||
getDocumentAsset={getDocumentAsset}
|
|
||||||
alt={`Thumbnail for ${titleText}`}
|
|
||||||
scrollRootRef={scrollRef}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="doc-list__name">
|
|
||||||
<div className="doc-name">
|
|
||||||
<div className="doc-list__name-content">
|
|
||||||
<span className="doc-name__title">
|
|
||||||
{correspondents.length > 0 ? (
|
|
||||||
<span className="doc-correspondents">
|
|
||||||
{renderCorrespondentLinks(correspondents)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="doc-name__primary">{titleText}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(doc.tags || []).length > 0 && (
|
|
||||||
<div className="doc-name__tags">
|
|
||||||
{(doc.tags || []).map((tag) => {
|
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
|
||||||
const style = getTagColorStyle(colorSource);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={tag.id}
|
|
||||||
className="badge tag-chip"
|
|
||||||
style={style || undefined}
|
|
||||||
title={tag.label}
|
|
||||||
onClick={(event) => {
|
|
||||||
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}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{(() => {
|
|
||||||
const issuedAt = doc.issued_at || null;
|
|
||||||
if (!issuedAt) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const timestamp = Date.parse(issuedAt);
|
|
||||||
if (Number.isNaN(timestamp)) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return new Date(timestamp).toLocaleDateString();
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
<td className="actions">
|
|
||||||
<div className="action-buttons">
|
|
||||||
{onDocumentRename && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
title="Rename"
|
|
||||||
aria-label={`Rename document ${titleText}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const nextName = window.prompt(
|
|
||||||
'Rename document',
|
|
||||||
doc.title
|
|
||||||
);
|
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === doc.title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDocumentRename(doc.id, trimmed);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{downloadHref ? (
|
|
||||||
<a
|
|
||||||
className="icon-button"
|
|
||||||
href={downloadHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="Download"
|
|
||||||
aria-label="Download document"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onAuxClick={(event) => event.stopPropagation()}
|
|
||||||
onContextMenu={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="icon-inline" />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="meta">No download</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger"
|
|
||||||
title="Delete"
|
|
||||||
aria-label="Delete document"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDocumentDelete?.(doc.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
draggingSet,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
getDownloadHref,
|
|
||||||
handleDocumentClick,
|
|
||||||
handleDocumentDragEndLocal,
|
|
||||||
handleDocumentDragStartLocal,
|
|
||||||
handleDocumentTagDragLeave,
|
|
||||||
handleDocumentTagDragOver,
|
|
||||||
handleDocumentTagDrop,
|
|
||||||
onDocumentDelete,
|
|
||||||
onDocumentOpen,
|
|
||||||
onDocumentRename,
|
|
||||||
onTagClick,
|
|
||||||
renderCorrespondentLinks,
|
|
||||||
scrollRef,
|
|
||||||
selectedSet,
|
|
||||||
tagLookupById,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasDocumentEntries = useMemo(
|
const hasDocumentEntries = useMemo(
|
||||||
() => entries.some((entry) => entry.type === EntryType.document),
|
() => entries.some((entry) => entry.type === EntryType.document),
|
||||||
[entries],
|
[entries],
|
||||||
@@ -1184,40 +438,71 @@ const DocumentsPanel = ({
|
|||||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
>
|
>
|
||||||
{isGridView ? (
|
{isGridView ? (
|
||||||
<div
|
<DocumentsGrid
|
||||||
className="documents-grid"
|
entries={entries}
|
||||||
role="list"
|
selectedDocumentIdsSet={selectedSet}
|
||||||
onClick={(event) => {
|
selectedFolderIdsSet={selectedFolderSet}
|
||||||
if (event.target === event.currentTarget) {
|
draggingDocumentIdsSet={draggingSet}
|
||||||
onClearSelection?.();
|
draggedFolderId={draggedFolderId}
|
||||||
}
|
onFolderClick={handleFolderClick}
|
||||||
}}
|
onFolderSelect={onFolderSelect}
|
||||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
onFolderDragOver={onFolderDragOver}
|
||||||
>
|
onFolderDragLeave={onFolderDragLeave}
|
||||||
{entries.map((entry) =>
|
onFolderDrop={onFolderDrop}
|
||||||
entry.type === EntryType.folder
|
onFolderDragStart={onFolderDragStart}
|
||||||
? renderFolderGridEntry(entry)
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
: renderDocumentGridEntry(entry)
|
onDocumentClick={handleDocumentClick}
|
||||||
)}
|
onDocumentOpen={onDocumentOpen}
|
||||||
</div>
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
gridIconSize={gridIconSize}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
/>
|
||||||
) : !showTableRows ? null : (
|
) : !showTableRows ? null : (
|
||||||
<table aria-multiselectable="true">
|
<DocumentsList
|
||||||
<thead>
|
entries={entries}
|
||||||
<tr>
|
focusedRowKey={focusedRowKey}
|
||||||
<th className="thumb-column"></th>
|
selectedDocumentIdsSet={selectedSet}
|
||||||
<th>Name</th>
|
selectedFolderIdsSet={selectedFolderSet}
|
||||||
<th>Issued</th>
|
draggingDocumentIdsSet={draggingSet}
|
||||||
<th className="actions-column">Actions</th>
|
draggedFolderId={draggedFolderId}
|
||||||
</tr>
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
</thead>
|
getDocumentAsset={getDocumentAsset}
|
||||||
<tbody>
|
getDownloadHref={getDownloadHref}
|
||||||
{entries.map((entry) =>
|
onFolderClick={handleFolderClick}
|
||||||
entry.type === EntryType.folder
|
onFolderSelect={onFolderSelect}
|
||||||
? renderFolderListEntry(entry)
|
onFolderDragOver={onFolderDragOver}
|
||||||
: renderDocumentListEntry(entry)
|
onFolderDragLeave={onFolderDragLeave}
|
||||||
)}
|
onFolderDrop={onFolderDrop}
|
||||||
</tbody>
|
onFolderDragStart={onFolderDragStart}
|
||||||
</table>
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
|
onFolderDelete={onFolderDelete}
|
||||||
|
onDocumentClick={handleDocumentClick}
|
||||||
|
onDocumentOpen={onDocumentOpen}
|
||||||
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
onDocumentDelete={onDocumentDelete}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showSearchHint && (
|
{showSearchHint && (
|
||||||
@@ -1232,7 +517,6 @@ const DocumentsPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default DocumentsPanel;
|
export default DocumentsPanel;
|
||||||
export { DocumentThumbnailImage };
|
|
||||||
|
|
||||||
export const createDocumentsTableHeaderActions = ({
|
export const createDocumentsTableHeaderActions = ({
|
||||||
viewMode,
|
viewMode,
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user