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
+33
View File
@@ -111,6 +111,39 @@
transition: transform 0.28s ease;
}
.desk-item__correspondents {
position: absolute;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-start;
transform-origin: bottom left;
transform: translate(0.5em, -0.5em);
pointer-events: none;
}
.desk-correspondent-chip {
pointer-events: none;
font-size: 0.82rem;
padding: 0.18rem 0.55rem;
max-width: min(16rem, 80%);
display: inline-flex;
align-items: center;
overflow: hidden;
box-shadow: 2px 2px 4px var(--shadow-faint);
background: color-mix(in oklch, var(--surface-subtle) 88%, transparent);
color: var(--muted);
}
.desk-correspondent-chip__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-chip--draggable {
user-select: none;
+17
View File
@@ -12,6 +12,7 @@ import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
import { resolveCorrespondents } from './documents/correspondents';
import DetailPanel from './detail/DetailPanel';
import { clamp, formatTransform } from './desktop/math';
import { preventAll } from './desktop/events';
@@ -2204,6 +2205,7 @@ const DesktopWorkspaceView = () => {
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : [];
const correspondents = resolveCorrespondents(doc);
const docTagKeys = tags
.map((tag) => (tag ? tag.id : null))
.filter(Boolean);
@@ -2298,6 +2300,21 @@ const DesktopWorkspaceView = () => {
onNavigatorSnapshot={handleNavigatorSnapshot}
shouldLoad={shouldLoad}
/>
{correspondents.length > 0 && (
<div className="desk-item__correspondents" aria-hidden="true">
{correspondents.map((correspondent) => (
<span
key={correspondent.key}
className="badge desk-correspondent-chip"
title={correspondent.name}
>
<span className="desk-correspondent-chip__label">
{correspondent.name}
</span>
</span>
))}
</div>
)}
{tags.length > 0 && (
<div className="desk-item__tags" aria-hidden="true">
{tags.map((tag) => {
+16 -132
View File
@@ -371,7 +371,7 @@ const AppLayout = () => {
setFocusedRowKey,
applySelection,
clearSelection: clearSelectionInternal,
handleRowSelection: handleRowSelectionInternal,
handleEntrySelection: handleEntrySelectionInternal,
promoteSelectionOrder: promoteSelectionOrderInternal,
configureSelectionEnvironment,
} = useDocumentSelection({
@@ -784,44 +784,6 @@ const AppLayout = () => {
[mapDocumentCaches],
);
const removeDocumentFromCaches = useCallback(
(documentId) => {
if (!documentId) {
return;
}
const removeFromList = (list) => {
const next = list.filter((doc) => doc.id !== documentId);
return next.length === list.length ? list : next;
};
setDocuments((prev) => removeFromList(prev));
setSearchResults((prev) => (Array.isArray(prev) ? removeFromList(prev) : prev));
setFolderContents((prev) => {
if (!prev.size) {
return prev;
}
let changed = false;
const next = new Map();
prev.forEach((contents, key) => {
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
if (!docs || docs.length === 0) {
next.set(key, contents);
return;
}
const filteredDocs = docs.filter((doc) => doc.id !== documentId);
if (filteredDocs.length !== docs.length) {
changed = true;
next.set(key, { ...contents, documents: filteredDocs });
} else {
next.set(key, contents);
}
});
return changed ? next : prev;
});
},
[setDocuments, setSearchResults, setFolderContents],
);
const folderOptions = useMemo(() => {
@@ -900,11 +862,11 @@ const AppLayout = () => {
});
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
const handleRowSelection = useCallback(
const handleEntrySelection = useCallback(
(rowKey, event) => {
handleRowSelectionInternal(rowKey, event);
handleEntrySelectionInternal(rowKey, event);
},
[handleRowSelectionInternal],
[handleEntrySelectionInternal],
);
const promoteSelectionOrder = useCallback(
@@ -2462,7 +2424,7 @@ const AppLayout = () => {
if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [folderId];
effectiveDocumentSelection = [];
handleRowSelection(folderKey, { preventDefault: () => {} });
handleEntrySelection(folderKey, { preventDefault: () => {} });
}
const uniqueFolders = effectiveFolderSelection.length
@@ -2513,7 +2475,7 @@ const AppLayout = () => {
selectedDocumentIds,
setDraggedFolderId,
setDraggedDocumentIds,
handleRowSelection,
handleEntrySelection,
documentLookup,
createDragPreview,
],
@@ -3150,7 +3112,7 @@ const AppLayout = () => {
if (!row) {
return;
}
handleRowSelection(row.key, event);
handleEntrySelection(row.key, event);
if (row.type === 'folder') {
selectFolder(row.id);
} else if (row.type === 'document') {
@@ -3163,7 +3125,7 @@ const AppLayout = () => {
if (key === 'ArrowDown') {
if (initializedFromEmptyState && selectedEntries.length === 0) {
handleRowSelection(activeKey, {
handleEntrySelection(activeKey, {
shiftKey,
preventDefault: () => {},
});
@@ -3172,7 +3134,7 @@ const AppLayout = () => {
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
} else if (key === 'ArrowUp') {
if (initializedFromEmptyState && selectedEntries.length === 0) {
handleRowSelection(activeKey, {
handleEntrySelection(activeKey, {
shiftKey,
preventDefault: () => {},
});
@@ -3200,7 +3162,7 @@ const AppLayout = () => {
setFocusedRowKey(targetRow.key);
handleRowSelection(targetRow.key, {
handleEntrySelection(targetRow.key, {
shiftKey,
preventDefault: () => {},
});
@@ -3210,7 +3172,7 @@ const AppLayout = () => {
navigableRowKeys,
focusedRowKey,
selectedEntries,
handleRowSelection,
handleEntrySelection,
selectFolder,
openDocumentPreview,
setFocusedRowKey,
@@ -4163,76 +4125,6 @@ const AppLayout = () => {
return list.find((doc) => doc.id === focusedDocumentId) || null;
}, [searchResults, documents, focusedDocumentId]);
const handleDocumentDelete = useCallback(
async (documentId) => {
if (!documentId) return;
if (!token) {
setStatusMessage('Log in to manage documents.', 'error');
return;
}
const doc = documentLookup.get(documentId) || null;
const label = doc?.title;
const confirmed = window.confirm(`Move "${label}" to trash? You can restore it from trash later.`);
if (!confirmed) {
return;
}
setLoading(true);
try {
await api.delete(`/documents/${documentId}`);
removeDocumentFromCaches(documentId);
setPreviewEntries((prev) => {
if (!prev.has(documentId)) {
return prev;
}
const next = new Map(prev);
next.delete(documentId);
return next;
});
previewInflightRef.current.delete(documentId);
if (selectedDocumentIds.includes(documentId)) {
const remainingRowKeys = selectedDocumentIds
.filter((id) => id !== documentId)
.map((id) => resolveDocumentRowKey(id))
.filter(Boolean);
const removedKey = resolveDocumentRowKey(documentId);
applySelection(remainingRowKeys, {
anchor: null,
interactedKeys: removedKey ? [removedKey] : [],
});
}
if (previewDocumentId === documentId) {
closeDocumentPreview();
}
setStatusMessage('Document deleted.', 'success');
} catch (error) {
const message = error.response?.data?.error || 'Failed to delete document.';
notifyApiError(error, message);
} finally {
setLoading(false);
}
},
[
token,
documentLookup,
setStatusMessage,
removeDocumentFromCaches,
setPreviewEntries,
previewInflightRef,
previewDocumentId,
closeDocumentPreview,
selectedDocumentIds,
applySelection,
notifyApiError,
],
);
const orderedSelectedDocuments = useMemo(() => {
const ordered = [];
@@ -4591,10 +4483,8 @@ const AppLayout = () => {
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onFolderDelete: handleFolderDelete,
onFolderRename: handleFolderRename,
onDocumentOpen: openDocumentPreview,
onDocumentDelete: handleDocumentDelete,
onDocumentRename: handleDocumentTitleUpdate,
selectedDocumentIds,
selectedFolderIds,
@@ -4610,17 +4500,13 @@ const AppLayout = () => {
onFocusedRowChange: setFocusedRowKey,
ensureAssetUrl,
getDocumentAsset,
getDownloadHref: (doc) =>
doc?.current_version?.download_path
? resolveApiPath(doc.current_version.download_path)
: null,
onTagClick: toggleTagFilter,
onCorrespondentClick: toggleCorrespondentFilter,
onDocumentTagDrop: handleDocumentTagDrop,
viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange,
onClearSelection: clearDocumentSelection,
onRowSelection: handleRowSelection,
onEntrySelection: handleEntrySelection,
onOpenDetailPanel: openDetailPanel,
}),
[
@@ -4635,7 +4521,6 @@ const AppLayout = () => {
draggedFolderId,
focusedRowKey,
folderClickHandlers,
handleDocumentDelete,
handleDocumentDragEnd,
handleDocumentDragStart,
handleDocumentListFocus,
@@ -4643,11 +4528,10 @@ const AppLayout = () => {
handleDocumentTagDrop,
handleDocumentTitleUpdate,
handleDocumentsViewModeChange,
handleFolderDelete,
handleFolderDragEnd,
handleFolderDragStart,
handleFolderRename,
handleRowSelection,
handleEntrySelection,
isFilterActive,
openDetailPanel,
openDocumentPreview,
@@ -4715,9 +4599,9 @@ const AppLayout = () => {
currentTenantId,
folderClickHandlers,
folderNodes,
handleFolderDelete,
handleFolderDragEnd,
handleFolderDragStart,
handleFolderDelete,
handleFolderRename,
handleLogout,
handleSearchChange,
@@ -4830,9 +4714,9 @@ const AppLayout = () => {
if (!rowKey) {
return;
}
handleRowSelection(rowKey, event);
handleEntrySelection(rowKey, event);
},
[handleRowSelection],
[handleEntrySelection],
);
const handleDeskDocumentStackSelect = useCallback(
+2 -2
View File
@@ -143,7 +143,7 @@ export const useDocumentSelection = ({
applySelection([], { anchor: null, interactedKeys: [] });
}, [applySelection]);
const handleRowSelection = useCallback(
const handleEntrySelection = useCallback(
(rowKey, event) => {
const visibleRowKeySet = visibleRowKeySetRef.current;
const navigableRowKeys = navigableRowKeysRef.current;
@@ -241,7 +241,7 @@ export const useDocumentSelection = ({
setFocusedRowKey,
applySelection,
clearSelection,
handleRowSelection,
handleEntrySelection,
promoteSelectionOrder,
configureSelectionEnvironment,
};
+208 -7
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { FolderIcon } from '../ui/icons';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
import { getTagColorStyle } from '../utils/colors';
import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename';
const DocumentsGrid = ({
entries,
@@ -35,7 +36,42 @@ const DocumentsGrid = ({
onCorrespondentClick,
activeCorrespondentIdSet,
onClearSelection,
}) => (
onDocumentRename,
onFolderRename,
}) => {
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 (
<div
className="documents-grid"
role="list"
@@ -58,6 +94,14 @@ const DocumentsGrid = ({
const classes = ['document-card', 'folder-card'];
if (isDraggingFolder) classes.push('is-dragging');
if (isSelectedFolder) classes.push('selected');
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 (
<div
@@ -89,9 +133,83 @@ const DocumentsGrid = ({
<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>
{isFolderEditing ? (
<div className="folder-card__edit 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>
</div>
) : (
<div className="folder-card__label-row">
<span
className="folder-card__name"
title={folder.name}
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>
</div>
)}
</div>
</div>
);
@@ -111,6 +229,13 @@ const DocumentsGrid = ({
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
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;
return (
<div
key={entry.key}
@@ -149,7 +274,82 @@ const DocumentsGrid = ({
/>
</span>
) : null}
<span className="doc-name__primary">{doc.title}</span>
{isEditingDoc ? (
<div className="document-card__title-edit 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>
</div>
) : (
<div className="document-card__title-row">
<span
className="document-card__title-badge"
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>
</div>
)}
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
@@ -204,6 +404,7 @@ const DocumentsGrid = ({
);
})}
</div>
);
);
};
export default DocumentsGrid;
+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;
+6 -10
View File
@@ -36,7 +36,6 @@ const DocumentsPanel = ({
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderDelete,
onFolderRename,
selectedFolderIds = [],
onDocumentOpen,
@@ -45,9 +44,8 @@ const DocumentsPanel = ({
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentDelete,
onDocumentRename,
onRowSelection = null,
onEntrySelection = null,
onOpenDetailPanel = null,
tagLookupById,
activeCorrespondentIds = [],
@@ -56,7 +54,6 @@ const DocumentsPanel = ({
onFocusedRowChange,
ensureAssetUrl = null,
getDocumentAsset = () => null,
getDownloadHref,
onTagClick,
onCorrespondentClick,
isSearchLoading = false,
@@ -253,8 +250,8 @@ const DocumentsPanel = ({
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
if (rowKey && typeof onRowSelection === 'function') {
onRowSelection(rowKey, event);
if (rowKey && typeof onEntrySelection === 'function') {
onEntrySelection(rowKey, event);
}
if (entry.type === EntryType.document) {
@@ -281,7 +278,7 @@ const DocumentsPanel = ({
onFocusedRowChange?.(rowKey);
}
},
[onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
[onEntrySelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
);
const handleDocumentClick = useCallback(
@@ -472,6 +469,8 @@ const DocumentsPanel = ({
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onClearSelection={onClearSelection}
onDocumentRename={onDocumentRename}
onFolderRename={onFolderRename}
/>
) : !showTableRows ? null : (
<DocumentsList
@@ -483,7 +482,6 @@ const DocumentsPanel = ({
draggedFolderId={draggedFolderId}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
getDownloadHref={getDownloadHref}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
@@ -492,7 +490,6 @@ const DocumentsPanel = ({
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onFolderRename={onFolderRename}
onFolderDelete={onFolderDelete}
onDocumentClick={handleDocumentClick}
onDocumentOpen={onDocumentOpen}
onDocumentDragStart={handleDocumentDragStartLocal}
@@ -501,7 +498,6 @@ const DocumentsPanel = ({
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
onDocumentRename={onDocumentRename}
onDocumentDelete={onDocumentDelete}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
onCorrespondentClick={onCorrespondentClick}
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useRef, useState } from 'react';
const focusInput = (node) => {
if (!node) {
return;
}
const applyFocus = () => {
node.focus();
if (typeof node.select === 'function') {
node.select();
}
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(applyFocus);
} else {
applyFocus();
}
};
const identity = (value) => value;
const useInlineRename = (
onRename,
{
getCurrentValue = identity,
getEntityId = (entity) => entity?.id ?? null,
} = {},
) => {
const [editingId, setEditingId] = useState(null);
const [draftValue, setDraftValue] = useState('');
const [savingId, setSavingId] = useState(null);
const inputRef = useRef(null);
const resetState = useCallback(() => {
setEditingId(null);
setDraftValue('');
setSavingId(null);
inputRef.current = null;
}, []);
const beginEditing = useCallback(
(entity, event) => {
if (!entity) {
return;
}
if (event) {
event.preventDefault();
event.stopPropagation();
}
const entityId = getEntityId(entity);
if (!entityId) {
return;
}
const currentValue = getCurrentValue(entity) ?? '';
setEditingId(entityId);
setDraftValue(currentValue);
setSavingId(null);
},
[getCurrentValue, getEntityId],
);
const cancelEditing = useCallback(
(event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
resetState();
},
[resetState],
);
const submitEditing = useCallback(
async (entity) => {
if (!entity) {
return false;
}
const entityId = getEntityId(entity);
if (!entityId || editingId !== entityId) {
return false;
}
const trimmed = draftValue.trim();
const currentValue = getCurrentValue(entity) ?? '';
if (!trimmed || trimmed === currentValue) {
resetState();
return true;
}
if (typeof onRename !== 'function') {
resetState();
return true;
}
setSavingId(entityId);
try {
const result = await onRename(entityId, trimmed);
if (result === false) {
return false;
}
resetState();
return true;
} catch (error) {
return false;
} finally {
setSavingId((current) => (current === entityId ? null : current));
}
},
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
);
const attachInputRef = useCallback(
(node) => {
if (node) {
inputRef.current = node;
focusInput(node);
} else if (inputRef.current) {
inputRef.current = null;
}
},
[],
);
return {
editingId,
draftValue,
setDraftValue,
beginEditing,
cancelEditing,
submitEditing,
savingId,
attachInputRef,
};
};
export default useInlineRename;
+74 -2
View File
@@ -2168,6 +2168,43 @@ button.danger:hover:not([disabled]) {
width: 100%;
}
.doc-title-edit {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: nowrap;
}
.doc-title-edit input[type='text'] {
padding: 0.3rem 0.55rem;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--fg);
min-width: 8rem;
}
.doc-title-edit input[type='text']:focus-visible {
outline: 2px solid var(--selection-ring);
outline-offset: 1px;
}
.doc-title-edit .icon-button {
flex-shrink: 0;
}
.documents-panel .doc-name__primary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.documents-panel .doc-name__primary-text {
overflow-wrap: anywhere;
}
.doc-entry {
display: flex;
align-items: center;
@@ -2294,16 +2331,31 @@ button.danger:hover:not([disabled]) {
}
.document-card__title {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
color: var(--fg);
}
.document-card__title-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.document-card__title-badge {
padding: 0.2rem 0.7rem;
border-radius: 1rem;
max-width: 100%;
word-break: break-word;
font-size: var(--documents-grid-title-size);
color: inherit;
}
.document-card.selected .document-card__title,
.folder-card.selected .folder-card__name {
.document-card.selected .document-card__title-badge {
background-color: var(--accent);
color: var(--on-accent);
}
@@ -2346,6 +2398,14 @@ button.danger:hover:not([disabled]) {
padding-top: 0.35rem;
}
.folder-card__label-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.folder-card__name {
color: var(--fg);
overflow: hidden;
@@ -2356,6 +2416,18 @@ button.danger:hover:not([disabled]) {
border-radius: 1rem;
}
.folder-card__edit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.folder-card.selected .folder-card__name {
background-color: var(--accent);
color: var(--on-accent);
}
.doc-name__title {
max-width: 100%;