1173 lines
43 KiB
React
1173 lines
43 KiB
React
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
|
import { getTagColorStyle } from '../utils/colors';
|
|
import DetailPanel from '../detail/DetailPanel';
|
|
import {
|
|
DownloadIcon,
|
|
EditIcon,
|
|
ViewListIcon,
|
|
ViewGridIcon,
|
|
DesktopIcon,
|
|
FolderIcon,
|
|
TrashIcon,
|
|
RefreshIcon,
|
|
MinusVerticalIcon,
|
|
ArrowUpIcon,
|
|
} from '../ui/icons';
|
|
|
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
|
const LIST_ICON_SIZE = 48;
|
|
|
|
const getPageCount = (doc) =>
|
|
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
|
? doc.current_version.metadata.page_count
|
|
: null;
|
|
|
|
const resolveCorrespondents = (doc) => {
|
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
|
return [];
|
|
}
|
|
|
|
const seen = new Set();
|
|
const results = [];
|
|
|
|
doc.correspondents.forEach((entry, index) => {
|
|
if (!entry || typeof entry.name !== 'string') return;
|
|
|
|
const id = entry.id;
|
|
const name = entry.name.trim();
|
|
if (!name) return;
|
|
|
|
if (id && seen.has(id)) {
|
|
return;
|
|
}
|
|
|
|
if (id) {
|
|
seen.add(id);
|
|
}
|
|
|
|
results.push({
|
|
id,
|
|
name,
|
|
key: id ?? `${name}-${index}`,
|
|
});
|
|
});
|
|
|
|
return results;
|
|
};
|
|
|
|
// Detects when an element becomes visible within a scroll container.
|
|
const useLazyVisibility = (rootRef, resetKey) => {
|
|
const targetRef = useRef(null);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsVisible(false);
|
|
}, [resetKey]);
|
|
|
|
const rootNode = rootRef?.current || null;
|
|
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
return undefined;
|
|
}
|
|
const element = targetRef.current;
|
|
if (!element) {
|
|
return undefined;
|
|
}
|
|
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
|
setIsVisible(true);
|
|
return undefined;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
setIsVisible(true);
|
|
observer.disconnect();
|
|
}
|
|
});
|
|
},
|
|
{
|
|
root: rootNode,
|
|
rootMargin: '200px 0px',
|
|
threshold: 0.01,
|
|
},
|
|
);
|
|
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, [isVisible, rootNode, resetKey]);
|
|
|
|
return { ref: targetRef, isVisible };
|
|
};
|
|
|
|
const DocumentThumbnailImage = ({
|
|
document,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
alt = '',
|
|
maxSize = LIST_ICON_SIZE,
|
|
scrollRootRef = null,
|
|
}) => {
|
|
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
|
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
|
const thumbnailAsset = useMemo(
|
|
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
|
[document?.current_version],
|
|
);
|
|
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
|
|
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
|
|
const assetWidth = Number(primaryMetadata?.width);
|
|
const assetHeight = Number(primaryMetadata?.height);
|
|
|
|
const dimensions = useMemo(() => {
|
|
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
|
|
return { width: resolvedMaxSize, height: resolvedMaxSize };
|
|
}
|
|
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
|
|
return {
|
|
width: Math.max(1, Math.round(assetWidth * scale)),
|
|
height: Math.max(1, Math.round(assetHeight * scale)),
|
|
};
|
|
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
|
|
|
const innerStyle = useMemo(
|
|
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
|
[dimensions.height, dimensions.width],
|
|
);
|
|
const url = useMemo(() => {
|
|
if (!isVisible) {
|
|
return null;
|
|
}
|
|
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
});
|
|
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
|
|
|
const pageCount = getPageCount(document);
|
|
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
|
const innerClasses = ['document-thumbnail-inner'];
|
|
if (showMultiPageBadge) {
|
|
innerClasses.push('document-thumbnail-inner--multipage');
|
|
}
|
|
|
|
const aspectRatio = useMemo(() => {
|
|
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
|
|
return assetWidth / assetHeight;
|
|
}
|
|
return null;
|
|
}, [assetWidth, assetHeight]);
|
|
|
|
useEffect(() => {
|
|
const node = visibilityRef.current;
|
|
if (!node) {
|
|
return;
|
|
}
|
|
if (aspectRatio) {
|
|
node.dataset.thumbnailAspect = String(aspectRatio);
|
|
} else {
|
|
delete node.dataset.thumbnailAspect;
|
|
}
|
|
}, [aspectRatio, visibilityRef]);
|
|
|
|
return (
|
|
<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 DocumentsTable = ({
|
|
currentFolderName,
|
|
onRefresh,
|
|
subfolders,
|
|
documents,
|
|
searchResults,
|
|
onFolderSelect,
|
|
onFolderDrop,
|
|
onFolderDragOver,
|
|
onFolderDragLeave,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
draggedFolderId,
|
|
onFolderDelete,
|
|
selectedFolderIds = [],
|
|
onFolderRowClick,
|
|
onDocumentRowClick,
|
|
onDocumentOpen,
|
|
selectedDocumentIds,
|
|
focusedRowKey,
|
|
draggingDocumentIds = [],
|
|
onDocumentDragStart,
|
|
onDocumentDragEnd,
|
|
onDocumentDelete,
|
|
onFolderRename,
|
|
onDocumentRename,
|
|
tagLookupById,
|
|
activeCorrespondentIds = [],
|
|
onDocumentListFocus,
|
|
onDocumentListKeyDown,
|
|
onFocusedRowChange,
|
|
ensureAssetUrl = null,
|
|
getDocumentAsset = () => null,
|
|
getDownloadHref,
|
|
onTagClick,
|
|
onCorrespondentClick,
|
|
isSearchLoading = false,
|
|
onDocumentTagDrop,
|
|
viewMode = 'list',
|
|
onViewModeChange,
|
|
onClearSelection,
|
|
showHeader = true,
|
|
}) => {
|
|
const showingSearchResults = searchResults !== null;
|
|
const rows = showingSearchResults ? searchResults : documents;
|
|
|
|
const selectedSet = useMemo(
|
|
() => new Set(selectedDocumentIds),
|
|
[selectedDocumentIds],
|
|
);
|
|
const selectedFolderSet = useMemo(
|
|
() => new Set(selectedFolderIds || []),
|
|
[selectedFolderIds],
|
|
);
|
|
const draggingSet = useMemo(
|
|
() => new Set(draggingDocumentIds || []),
|
|
[draggingDocumentIds],
|
|
);
|
|
const activeCorrespondentIdSet = useMemo(
|
|
() => new Set(activeCorrespondentIds || []),
|
|
[activeCorrespondentIds],
|
|
);
|
|
const scrollRef = useRef(null);
|
|
const suppressDocumentClickRef = useRef(false);
|
|
const [, forceVisibilityTick] = useState(0);
|
|
const lastScrollNodeRef = useRef(null);
|
|
const assignScrollRef = useCallback((node) => {
|
|
if (lastScrollNodeRef.current === node) {
|
|
return;
|
|
}
|
|
lastScrollNodeRef.current = node;
|
|
scrollRef.current = node;
|
|
if (node) {
|
|
forceVisibilityTick((value) => value + 1);
|
|
}
|
|
}, []);
|
|
const isGridView = viewMode === 'grid';
|
|
const isDeskView = viewMode === 'desk';
|
|
const isListView = viewMode === 'list';
|
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
|
const handleSetViewMode = useCallback(
|
|
(nextMode) => {
|
|
if (!onViewModeChange) {
|
|
return;
|
|
}
|
|
onViewModeChange(nextMode);
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = 0;
|
|
}
|
|
},
|
|
[onViewModeChange],
|
|
);
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = 0;
|
|
}
|
|
}, [viewMode]);
|
|
const isTagDragEvent = useCallback((event) => {
|
|
const types = Array.from(event.dataTransfer?.types || []);
|
|
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
|
}, []);
|
|
const ensureFocusedRowVisible = useCallback(() => {
|
|
if (!focusedRowKey) return;
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
let selector = null;
|
|
if (focusedRowKey.startsWith('document:')) {
|
|
selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
|
|
} else if (focusedRowKey.startsWith('folder:')) {
|
|
selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
|
}
|
|
if (!selector) {
|
|
return;
|
|
}
|
|
const row = container.querySelector(selector);
|
|
if (!row || !container.contains(row)) {
|
|
return;
|
|
}
|
|
|
|
const header = container.querySelector('thead');
|
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
|
const rowTop = row.offsetTop;
|
|
const rowBottom = rowTop + row.offsetHeight;
|
|
const visibleTop = container.scrollTop + headerHeight;
|
|
const visibleBottom = container.scrollTop + container.clientHeight;
|
|
|
|
if (rowTop < visibleTop) {
|
|
container.scrollTop = Math.max(rowTop - headerHeight, 0);
|
|
return;
|
|
}
|
|
|
|
if (rowBottom > visibleBottom) {
|
|
const nextScrollTop = rowBottom - container.clientHeight;
|
|
container.scrollTop = Math.max(nextScrollTop, 0);
|
|
}
|
|
}, [focusedRowKey]);
|
|
|
|
useEffect(() => {
|
|
ensureFocusedRowVisible();
|
|
}, [ensureFocusedRowVisible]);
|
|
|
|
const activeDescendantId = useMemo(() => {
|
|
if (!focusedRowKey) return undefined;
|
|
if (focusedRowKey.startsWith('document:')) {
|
|
return `document-row-${focusedRowKey.slice('document:'.length)}`;
|
|
}
|
|
if (focusedRowKey.startsWith('folder:')) {
|
|
return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
|
}
|
|
return undefined;
|
|
}, [focusedRowKey]);
|
|
|
|
const handleDocumentTagDragOver = useCallback(
|
|
(event) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
event.currentTarget.classList.add('tag-drop-target');
|
|
},
|
|
[isTagDragEvent],
|
|
);
|
|
|
|
const handleDocumentTagDragLeave = useCallback(
|
|
(event) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
event.currentTarget.classList.remove('tag-drop-target');
|
|
},
|
|
[isTagDragEvent],
|
|
);
|
|
|
|
const handleDocumentTagDrop = useCallback(
|
|
(event, documentId) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.currentTarget.classList.remove('tag-drop-target');
|
|
const payload =
|
|
event.dataTransfer.getData('application/x-papercrate-tag') ||
|
|
event.dataTransfer.getData('text/papercrate-tag');
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(payload);
|
|
if (parsed?.id && onDocumentTagDrop) {
|
|
onDocumentTagDrop(documentId, parsed);
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to parse tag drop payload', error);
|
|
}
|
|
},
|
|
[isTagDragEvent, onDocumentTagDrop],
|
|
);
|
|
|
|
const handleDocumentClick = useCallback(
|
|
(documentId, event) => {
|
|
if (suppressDocumentClickRef.current) {
|
|
return;
|
|
}
|
|
onDocumentRowClick?.(documentId, event);
|
|
},
|
|
[onDocumentRowClick],
|
|
);
|
|
|
|
const handleDocumentDragStartLocal = useCallback(
|
|
(event, doc) => {
|
|
suppressDocumentClickRef.current = true;
|
|
onDocumentDragStart?.(event, doc);
|
|
},
|
|
[onDocumentDragStart],
|
|
);
|
|
|
|
const handleDocumentDragEndLocal = useCallback(
|
|
(event) => {
|
|
onDocumentDragEnd?.(event);
|
|
requestAnimationFrame(() => {
|
|
suppressDocumentClickRef.current = false;
|
|
});
|
|
},
|
|
[onDocumentDragEnd],
|
|
);
|
|
|
|
const renderCorrespondentLinks = useCallback(
|
|
(correspondents) =>
|
|
correspondents.map((correspondent, index) => {
|
|
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
|
|
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
|
|
const classNames = ['doc-correspondent-link'];
|
|
if (isActive) classNames.push('is-active');
|
|
if (!hasClickHandler) classNames.push('is-static');
|
|
const isLast = index === correspondents.length - 1;
|
|
const label = isLast
|
|
? `${correspondent.name}:${String.fromCharCode(160)}`
|
|
: correspondent.name;
|
|
return (
|
|
<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 showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
|
const showListSearchEmptyState =
|
|
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
|
const showGridSearchEmptyState =
|
|
isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading;
|
|
const showSearchHint = showingSearchResults && rows.length > 0;
|
|
|
|
return (
|
|
<section
|
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
|
>
|
|
{showHeader ? (
|
|
<div className="panel-section__header">
|
|
<div className="panel-section__titles">
|
|
<h2>{currentFolderName}</h2>
|
|
{showingSearchResults && (
|
|
<div className="panel-section__subtitle">Search results</div>
|
|
)}
|
|
</div>
|
|
<div className="header-actions">
|
|
<div className="view-toggle" role="group" aria-label="Change view">
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
|
onClick={() => handleSetViewMode('list')}
|
|
aria-pressed={isListView}
|
|
title="List view"
|
|
>
|
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
|
onClick={() => handleSetViewMode('grid')}
|
|
aria-pressed={isGridView}
|
|
title="Icons view"
|
|
>
|
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
|
onClick={() => handleSetViewMode('desk')}
|
|
aria-pressed={isDeskView}
|
|
title="Desk view"
|
|
>
|
|
<DesktopIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
</div>
|
|
<button className="secondary" onClick={onRefresh}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{showDefaultEmptyState && (
|
|
<div className="empty-state">
|
|
Drop files anywhere or onto a folder to upload documents.
|
|
</div>
|
|
)}
|
|
{showGridSearchEmptyState && (
|
|
<div className="empty-state empty-state--global">
|
|
No documents match the current filters.
|
|
</div>
|
|
)}
|
|
<div className="panel-section__body">
|
|
<div
|
|
ref={assignScrollRef}
|
|
className="documents-scroll"
|
|
onFocus={(event) => {
|
|
if (event.target === scrollRef.current) {
|
|
onDocumentListFocus?.();
|
|
}
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.target !== scrollRef.current) {
|
|
return;
|
|
}
|
|
if (onDocumentListKeyDown) {
|
|
onDocumentListKeyDown(event);
|
|
}
|
|
}}
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClearSelection?.();
|
|
}
|
|
}}
|
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
|
>
|
|
{showDefaultEmptyState ? null : isGridView ? (
|
|
<div
|
|
className="documents-grid"
|
|
role="list"
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClearSelection?.();
|
|
}
|
|
}}
|
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
|
>
|
|
{!showingSearchResults &&
|
|
subfolders.map((folder) => {
|
|
const canDragFolder = folder.id !== 'root';
|
|
const isDraggingFolder = draggedFolderId === folder.id;
|
|
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
|
const classes = ['document-card', 'folder-card'];
|
|
if (isDraggingFolder) classes.push('is-dragging');
|
|
if (isSelectedFolder) classes.push('selected');
|
|
return (
|
|
<div
|
|
key={folder.id}
|
|
className={classes.join(' ')}
|
|
role="listitem"
|
|
id={`folder-card-${folder.id}`}
|
|
draggable={canDragFolder}
|
|
onClick={(event) => {
|
|
onFolderRowClick?.(folder.id, event);
|
|
const shouldNavigate =
|
|
!event.defaultPrevented &&
|
|
!event.metaKey &&
|
|
!event.ctrlKey &&
|
|
!event.shiftKey;
|
|
if (shouldNavigate) {
|
|
onFolderSelect(folder.id);
|
|
}
|
|
}}
|
|
onDoubleClick={(event) => {
|
|
event.preventDefault();
|
|
onFolderSelect(folder.id);
|
|
}}
|
|
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
|
onDragLeave={onFolderDragLeave}
|
|
onDrop={(event) => onFolderDrop(event, folder.id)}
|
|
onDragStart={(event) => {
|
|
if (canDragFolder) {
|
|
onFolderDragStart(event, folder.id);
|
|
}
|
|
}}
|
|
onDragEnd={(event) => {
|
|
if (canDragFolder) {
|
|
onFolderDragEnd(event);
|
|
}
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
{rows.map((doc) => {
|
|
const isSelected = selectedSet.has(doc.id);
|
|
const isDraggingDoc = draggingSet.has(doc.id);
|
|
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
|
const visibleTags = tagList.slice(0, 3);
|
|
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
|
const correspondents = resolveCorrespondents(doc);
|
|
const cardClasses = ['document-card', 'document'];
|
|
if (isSelected) cardClasses.push('selected');
|
|
if (isDraggingDoc) cardClasses.push('is-dragging');
|
|
const titleText = doc.title || doc.original_name;
|
|
|
|
return (
|
|
<div
|
|
key={doc.id}
|
|
className={cardClasses.join(' ')}
|
|
role="listitem"
|
|
id={`document-card-${doc.id}`}
|
|
data-doc-id={doc.id}
|
|
onClick={(event) => handleDocumentClick(doc.id, event)}
|
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
|
draggable
|
|
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
|
onDragEnd={handleDocumentDragEndLocal}
|
|
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
|
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
|
onDragLeave={handleDocumentTagDragLeave}
|
|
onDragLeaveCapture={handleDocumentTagDragLeave}
|
|
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
|
onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)}
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<table aria-multiselectable="true">
|
|
<thead>
|
|
<tr>
|
|
<th className="thumb-column">Preview</th>
|
|
<th>Name</th>
|
|
<th>Issued</th>
|
|
<th className="actions-column">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{!showingSearchResults &&
|
|
subfolders.map((folder) => {
|
|
const canDragFolder = folder.id !== 'root';
|
|
const isDraggingFolder = draggedFolderId === folder.id;
|
|
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
|
return (
|
|
<tr
|
|
key={folder.id}
|
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
|
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
|
}${isSelectedFolder ? ' selected' : ''}`}
|
|
id={`folder-row-${folder.id}`}
|
|
onClick={(event) => {
|
|
onFolderRowClick?.(folder.id, event);
|
|
const shouldNavigate =
|
|
!event.defaultPrevented &&
|
|
!event.metaKey &&
|
|
!event.ctrlKey &&
|
|
!event.shiftKey;
|
|
if (shouldNavigate) {
|
|
onFolderSelect(folder.id);
|
|
}
|
|
if (scrollRef.current) {
|
|
scrollRef.current.focus({ preventScroll: true });
|
|
}
|
|
onFocusedRowChange?.(`folder:${folder.id}`);
|
|
}}
|
|
onDoubleClick={(event) => {
|
|
event.preventDefault();
|
|
onFolderSelect(folder.id);
|
|
}}
|
|
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
|
onDragLeave={onFolderDragLeave}
|
|
onDrop={(event) => onFolderDrop(event, folder.id)}
|
|
draggable={canDragFolder}
|
|
onDragStart={(event) => {
|
|
if (canDragFolder) {
|
|
onFolderDragStart(event, folder.id);
|
|
}
|
|
}}
|
|
onDragEnd={(event) => {
|
|
if (canDragFolder) {
|
|
onFolderDragEnd(event);
|
|
}
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
{rows.map((doc) => {
|
|
const isSelected = selectedSet.has(doc.id);
|
|
const isDraggingDoc = draggingSet.has(doc.id);
|
|
const rowClasses = ['document'];
|
|
if (isSelected) rowClasses.push('selected');
|
|
if (isDraggingDoc) rowClasses.push('is-dragging');
|
|
const downloadHref = getDownloadHref?.(doc) || null;
|
|
const correspondents = resolveCorrespondents(doc);
|
|
const titleText = doc.title || doc.original_name;
|
|
|
|
return (
|
|
<tr
|
|
key={doc.id}
|
|
className={rowClasses.join(' ')}
|
|
id={`document-row-${doc.id}`}
|
|
data-doc-id={doc.id}
|
|
onClick={(event) => handleDocumentClick(doc.id, event)}
|
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
|
draggable
|
|
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
|
|
onDragEnd={handleDocumentDragEndLocal}
|
|
onDragOver={handleDocumentTagDragOver}
|
|
onDragLeave={handleDocumentTagDragLeave}
|
|
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
{showListSearchEmptyState && (
|
|
<div className="empty-state">No documents match the current filters.</div>
|
|
)}
|
|
{showSearchHint && (
|
|
<div className="search-hint">
|
|
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default DocumentsTable;
|
|
export { DocumentThumbnailImage };
|
|
|
|
export const createDocumentsTableHeaderActions = ({
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
}) => {
|
|
const isListView = viewMode === 'list';
|
|
const isGridView = viewMode === 'grid';
|
|
const isDeskView = viewMode === 'desk';
|
|
|
|
return (
|
|
<>
|
|
<div className="view-toggle" role="group" aria-label="Change view">
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
|
onClick={() => onViewModeChange?.('list')}
|
|
aria-pressed={isListView}
|
|
title="List view"
|
|
>
|
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
|
onClick={() => onViewModeChange?.('grid')}
|
|
aria-pressed={isGridView}
|
|
title="Icons view"
|
|
>
|
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
|
onClick={() => onViewModeChange?.('desk')}
|
|
aria-pressed={isDeskView}
|
|
title="Desk view"
|
|
>
|
|
<DesktopIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
</div>
|
|
<span className="main-content__actions-divider" aria-hidden="true">
|
|
<MinusVerticalIcon />
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={onRefresh}
|
|
aria-label="Refresh"
|
|
title="Refresh"
|
|
>
|
|
<RefreshIcon />
|
|
</button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const createDocumentsSurface = ({
|
|
tableProps,
|
|
parentBreadcrumb,
|
|
onNavigateParent,
|
|
renderSidebarToggle,
|
|
detailProps,
|
|
detailOpen = false,
|
|
}) => {
|
|
const {
|
|
currentFolderName,
|
|
searchResults,
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
} = tableProps;
|
|
|
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
|
const subtitle = Array.isArray(searchResults)
|
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
|
: null;
|
|
|
|
const actions = createDocumentsTableHeaderActions({
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
});
|
|
|
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
|
const parentControl = parentBreadcrumb
|
|
? (
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={onNavigateParent}
|
|
aria-label="Go to parent folder"
|
|
title="Go to parent folder"
|
|
>
|
|
<ArrowUpIcon />
|
|
</button>
|
|
)
|
|
: null;
|
|
const leading = sidebarToggle || parentControl
|
|
? (
|
|
<>
|
|
{sidebarToggle}
|
|
{parentControl}
|
|
</>
|
|
)
|
|
: null;
|
|
|
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
|
|
|
return {
|
|
key: 'documents',
|
|
variant: 'documents',
|
|
header: { title, subtitle, leading, actions },
|
|
content: <DocumentsTable {...tableProps} showHeader={false} />,
|
|
detail,
|
|
};
|
|
};
|