This commit is contained in:
2025-11-03 00:17:11 +01:00
parent c85cff720b
commit 2ee69406b6
9 changed files with 720 additions and 275 deletions
+231 -122
View File
@@ -1,15 +1,22 @@
import React from 'react';
import {
FolderIcon,
EditIcon,
DownloadIcon,
TrashIcon,
} from '../ui/icons';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename';
const formatDate = (value) => {
if (!value) {
return "—";
}
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) {
return "—";
}
return new Date(timestamp).toLocaleDateString();
};
const DocumentsList = ({
entries,
@@ -20,7 +27,6 @@ const DocumentsList = ({
draggedFolderId,
ensureAssetUrl,
getDocumentAsset,
getDownloadHref,
onFolderClick,
onFolderSelect,
onFolderDragOver,
@@ -29,7 +35,6 @@ const DocumentsList = ({
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
onFolderDelete,
onDocumentClick,
onDocumentOpen,
onDocumentDragStart,
@@ -38,20 +43,53 @@ const DocumentsList = ({
onDocumentTagDragLeave,
onDocumentTagDrop,
onDocumentRename,
onDocumentDelete,
tagLookupById,
onTagClick,
onCorrespondentClick,
activeCorrespondentIdSet,
scrollRef,
}) => (
}) => {
const {
editingId: editingDocumentId,
draftValue: documentDraft,
setDraftValue: setDocumentDraft,
beginEditing: beginDocumentEditing,
cancelEditing: cancelDocumentEditing,
submitEditing: submitDocumentEditing,
savingId: savingDocumentId,
attachInputRef: attachDocumentInputRef,
} = useInlineRename(onDocumentRename, {
getCurrentValue: (doc) => doc?.title ?? '',
getEntityId: (doc) => doc?.id ?? null,
});
const {
editingId: editingFolderId,
draftValue: folderDraft,
setDraftValue: setFolderDraft,
beginEditing: beginFolderEditing,
cancelEditing: cancelFolderEditing,
submitEditing: submitFolderEditing,
savingId: savingFolderId,
attachInputRef: attachFolderInputRef,
} = useInlineRename(onFolderRename, {
getCurrentValue: (folder) => folder?.name ?? '',
getEntityId: (folder) => folder?.id ?? null,
});
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
return (
<table aria-multiselectable="true">
<thead>
<tr>
<th className="thumb-column"></th>
<th>Name</th>
<th>Issued</th>
<th>Actions</th>
<th>Added</th>
</tr>
</thead>
<tbody>
@@ -65,6 +103,14 @@ const DocumentsList = ({
const isDraggingFolder = draggedFolderId === folder.id;
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
const rowKey = `folder:${folder.id}`;
const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function';
const isFolderEditing = editingFolderId === folder.id;
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
const isFolderSaving = savingFolderId === folder.id;
const canSubmitFolder =
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
return (
<tr
@@ -100,48 +146,90 @@ const DocumentsList = ({
</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>
<span className="doc-name__title">
<span className="doc-name__primary">
{isFolderEditing ? (
<span className="doc-title-edit">
<input
type="text"
ref={attachFolderInputRef}
value={folderDraftValue}
onChange={(event) => setFolderDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitFolderEditing(folder);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelFolderEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelFolderEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save name"
title="Save name"
disabled={!canSubmitFolder || isFolderSaving}
onClick={(event) => {
event.stopPropagation();
submitFolderEditing(folder);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
cancelFolderEditing(event);
}}
>
<CloseIcon />
</button>
</span>
) : (
<span
className="doc-name__primary-text"
role={allowInlineFolderEdit ? 'button' : undefined}
tabIndex={allowInlineFolderEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineFolderEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}}
onKeyDown={(event) => {
if (!allowInlineFolderEdit) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
beginFolderEditing(folder);
}
}}
>
{folder.name}
</span>
)}
</span>
</span>
</div>
</td>
<td></td>
<td></td>
</tr>
);
}
@@ -155,8 +243,17 @@ const DocumentsList = ({
const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null;
const correspondents = resolveCorrespondents(doc);
const isEditingDoc = editingDocumentId === doc.id;
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
const isDocumentSaving = savingDocumentId === doc.id;
const canSubmitDocument =
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
const allowInlineDocumentEdit =
onDocumentRename && isSelected && totalSelectionCount === 1;
const issuedLabel = formatDate(doc.issued_at);
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
return (
<tr
key={entry.key}
@@ -194,7 +291,84 @@ const DocumentsList = ({
/>
</span>
) : null}
<span className="doc-name__primary">{doc.title}</span>
<span className="doc-name__primary">
{isEditingDoc ? (
<span className="doc-title-edit">
<input
type="text"
ref={attachDocumentInputRef}
value={documentDraftValue}
onChange={(event) => setDocumentDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDocumentEditing(doc);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelDocumentEditing(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
cancelDocumentEditing();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save title"
title="Save title"
disabled={!canSubmitDocument || isDocumentSaving}
onClick={(event) => {
event.stopPropagation();
submitDocumentEditing(doc);
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
cancelDocumentEditing(event);
}}
>
<CloseIcon />
</button>
</span>
) : (
<span
className="doc-name__primary-text"
role={allowInlineDocumentEdit ? 'button' : undefined}
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
onClick={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}}
onKeyDown={(event) => {
if (!allowInlineDocumentEdit) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
beginDocumentEditing(doc);
}
}}
>
{doc.title}
</span>
)}
</span>
</span>
</div>
{(doc.tags || []).length > 0 && (
@@ -244,79 +418,14 @@ const DocumentsList = ({
)}
</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>
<td>{issuedLabel}</td>
<td>{addedLabel}</td>
</tr>
);
})}
</tbody>
</table>
);
);
};
export default DocumentsList;