323 lines
12 KiB
React
323 lines
12 KiB
React
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';
|
|
import { writeTagTransferData } from './tagTransfer';
|
|
|
|
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>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);
|
|
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 ${doc.title}`}
|
|
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">{doc.title}</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';
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to configure drag effect', error);
|
|
}
|
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
|
}}
|
|
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 ${doc.title}`}
|
|
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;
|