Files
papercrate/frontend/src/documents/DocumentsList.jsx
T
2025-11-02 04:40:07 +01:00

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;