stuff
This commit is contained in:
@@ -111,6 +111,39 @@
|
|||||||
transition: transform 0.28s ease;
|
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 {
|
.tag-chip--draggable {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAssetNavigator } from './hooks/useAssetNavigator';
|
|||||||
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
|
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
|
||||||
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
|
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
|
||||||
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
|
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
|
||||||
|
import { resolveCorrespondents } from './documents/correspondents';
|
||||||
import DetailPanel from './detail/DetailPanel';
|
import DetailPanel from './detail/DetailPanel';
|
||||||
import { clamp, formatTransform } from './desktop/math';
|
import { clamp, formatTransform } from './desktop/math';
|
||||||
import { preventAll } from './desktop/events';
|
import { preventAll } from './desktop/events';
|
||||||
@@ -2204,6 +2205,7 @@ const DesktopWorkspaceView = () => {
|
|||||||
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
|
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
|
||||||
const dragging = draggingId === doc.id;
|
const dragging = draggingId === doc.id;
|
||||||
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
|
const correspondents = resolveCorrespondents(doc);
|
||||||
const docTagKeys = tags
|
const docTagKeys = tags
|
||||||
.map((tag) => (tag ? tag.id : null))
|
.map((tag) => (tag ? tag.id : null))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -2298,6 +2300,21 @@ const DesktopWorkspaceView = () => {
|
|||||||
onNavigatorSnapshot={handleNavigatorSnapshot}
|
onNavigatorSnapshot={handleNavigatorSnapshot}
|
||||||
shouldLoad={shouldLoad}
|
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 && (
|
{tags.length > 0 && (
|
||||||
<div className="desk-item__tags" aria-hidden="true">
|
<div className="desk-item__tags" aria-hidden="true">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
|
|||||||
+16
-132
@@ -371,7 +371,7 @@ const AppLayout = () => {
|
|||||||
setFocusedRowKey,
|
setFocusedRowKey,
|
||||||
applySelection,
|
applySelection,
|
||||||
clearSelection: clearSelectionInternal,
|
clearSelection: clearSelectionInternal,
|
||||||
handleRowSelection: handleRowSelectionInternal,
|
handleEntrySelection: handleEntrySelectionInternal,
|
||||||
promoteSelectionOrder: promoteSelectionOrderInternal,
|
promoteSelectionOrder: promoteSelectionOrderInternal,
|
||||||
configureSelectionEnvironment,
|
configureSelectionEnvironment,
|
||||||
} = useDocumentSelection({
|
} = useDocumentSelection({
|
||||||
@@ -784,44 +784,6 @@ const AppLayout = () => {
|
|||||||
[mapDocumentCaches],
|
[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(() => {
|
const folderOptions = useMemo(() => {
|
||||||
@@ -900,11 +862,11 @@ const AppLayout = () => {
|
|||||||
});
|
});
|
||||||
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
||||||
|
|
||||||
const handleRowSelection = useCallback(
|
const handleEntrySelection = useCallback(
|
||||||
(rowKey, event) => {
|
(rowKey, event) => {
|
||||||
handleRowSelectionInternal(rowKey, event);
|
handleEntrySelectionInternal(rowKey, event);
|
||||||
},
|
},
|
||||||
[handleRowSelectionInternal],
|
[handleEntrySelectionInternal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const promoteSelectionOrder = useCallback(
|
const promoteSelectionOrder = useCallback(
|
||||||
@@ -2462,7 +2424,7 @@ const AppLayout = () => {
|
|||||||
if (!isAlreadySelected && folderKey) {
|
if (!isAlreadySelected && folderKey) {
|
||||||
effectiveFolderSelection = [folderId];
|
effectiveFolderSelection = [folderId];
|
||||||
effectiveDocumentSelection = [];
|
effectiveDocumentSelection = [];
|
||||||
handleRowSelection(folderKey, { preventDefault: () => {} });
|
handleEntrySelection(folderKey, { preventDefault: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueFolders = effectiveFolderSelection.length
|
const uniqueFolders = effectiveFolderSelection.length
|
||||||
@@ -2513,7 +2475,7 @@ const AppLayout = () => {
|
|||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
setDraggedFolderId,
|
setDraggedFolderId,
|
||||||
setDraggedDocumentIds,
|
setDraggedDocumentIds,
|
||||||
handleRowSelection,
|
handleEntrySelection,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
createDragPreview,
|
createDragPreview,
|
||||||
],
|
],
|
||||||
@@ -3150,7 +3112,7 @@ const AppLayout = () => {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleRowSelection(row.key, event);
|
handleEntrySelection(row.key, event);
|
||||||
if (row.type === 'folder') {
|
if (row.type === 'folder') {
|
||||||
selectFolder(row.id);
|
selectFolder(row.id);
|
||||||
} else if (row.type === 'document') {
|
} else if (row.type === 'document') {
|
||||||
@@ -3163,7 +3125,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
if (key === 'ArrowDown') {
|
if (key === 'ArrowDown') {
|
||||||
if (initializedFromEmptyState && selectedEntries.length === 0) {
|
if (initializedFromEmptyState && selectedEntries.length === 0) {
|
||||||
handleRowSelection(activeKey, {
|
handleEntrySelection(activeKey, {
|
||||||
shiftKey,
|
shiftKey,
|
||||||
preventDefault: () => {},
|
preventDefault: () => {},
|
||||||
});
|
});
|
||||||
@@ -3172,7 +3134,7 @@ const AppLayout = () => {
|
|||||||
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
|
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
|
||||||
} else if (key === 'ArrowUp') {
|
} else if (key === 'ArrowUp') {
|
||||||
if (initializedFromEmptyState && selectedEntries.length === 0) {
|
if (initializedFromEmptyState && selectedEntries.length === 0) {
|
||||||
handleRowSelection(activeKey, {
|
handleEntrySelection(activeKey, {
|
||||||
shiftKey,
|
shiftKey,
|
||||||
preventDefault: () => {},
|
preventDefault: () => {},
|
||||||
});
|
});
|
||||||
@@ -3200,7 +3162,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
setFocusedRowKey(targetRow.key);
|
setFocusedRowKey(targetRow.key);
|
||||||
|
|
||||||
handleRowSelection(targetRow.key, {
|
handleEntrySelection(targetRow.key, {
|
||||||
shiftKey,
|
shiftKey,
|
||||||
preventDefault: () => {},
|
preventDefault: () => {},
|
||||||
});
|
});
|
||||||
@@ -3210,7 +3172,7 @@ const AppLayout = () => {
|
|||||||
navigableRowKeys,
|
navigableRowKeys,
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
handleRowSelection,
|
handleEntrySelection,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
setFocusedRowKey,
|
setFocusedRowKey,
|
||||||
@@ -4163,76 +4125,6 @@ const AppLayout = () => {
|
|||||||
return list.find((doc) => doc.id === focusedDocumentId) || null;
|
return list.find((doc) => doc.id === focusedDocumentId) || null;
|
||||||
}, [searchResults, documents, focusedDocumentId]);
|
}, [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 orderedSelectedDocuments = useMemo(() => {
|
||||||
const ordered = [];
|
const ordered = [];
|
||||||
@@ -4591,10 +4483,8 @@ const AppLayout = () => {
|
|||||||
onFolderDragStart: handleFolderDragStart,
|
onFolderDragStart: handleFolderDragStart,
|
||||||
onFolderDragEnd: handleFolderDragEnd,
|
onFolderDragEnd: handleFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderDelete: handleFolderDelete,
|
|
||||||
onFolderRename: handleFolderRename,
|
onFolderRename: handleFolderRename,
|
||||||
onDocumentOpen: openDocumentPreview,
|
onDocumentOpen: openDocumentPreview,
|
||||||
onDocumentDelete: handleDocumentDelete,
|
|
||||||
onDocumentRename: handleDocumentTitleUpdate,
|
onDocumentRename: handleDocumentTitleUpdate,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedFolderIds,
|
selectedFolderIds,
|
||||||
@@ -4610,17 +4500,13 @@ const AppLayout = () => {
|
|||||||
onFocusedRowChange: setFocusedRowKey,
|
onFocusedRowChange: setFocusedRowKey,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
getDownloadHref: (doc) =>
|
|
||||||
doc?.current_version?.download_path
|
|
||||||
? resolveApiPath(doc.current_version.download_path)
|
|
||||||
: null,
|
|
||||||
onTagClick: toggleTagFilter,
|
onTagClick: toggleTagFilter,
|
||||||
onCorrespondentClick: toggleCorrespondentFilter,
|
onCorrespondentClick: toggleCorrespondentFilter,
|
||||||
onDocumentTagDrop: handleDocumentTagDrop,
|
onDocumentTagDrop: handleDocumentTagDrop,
|
||||||
viewMode: documentsViewMode,
|
viewMode: documentsViewMode,
|
||||||
onViewModeChange: handleDocumentsViewModeChange,
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
onClearSelection: clearDocumentSelection,
|
onClearSelection: clearDocumentSelection,
|
||||||
onRowSelection: handleRowSelection,
|
onEntrySelection: handleEntrySelection,
|
||||||
onOpenDetailPanel: openDetailPanel,
|
onOpenDetailPanel: openDetailPanel,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -4635,7 +4521,6 @@ const AppLayout = () => {
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
folderClickHandlers,
|
folderClickHandlers,
|
||||||
handleDocumentDelete,
|
|
||||||
handleDocumentDragEnd,
|
handleDocumentDragEnd,
|
||||||
handleDocumentDragStart,
|
handleDocumentDragStart,
|
||||||
handleDocumentListFocus,
|
handleDocumentListFocus,
|
||||||
@@ -4643,11 +4528,10 @@ const AppLayout = () => {
|
|||||||
handleDocumentTagDrop,
|
handleDocumentTagDrop,
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentsViewModeChange,
|
handleDocumentsViewModeChange,
|
||||||
handleFolderDelete,
|
|
||||||
handleFolderDragEnd,
|
handleFolderDragEnd,
|
||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleFolderRename,
|
handleFolderRename,
|
||||||
handleRowSelection,
|
handleEntrySelection,
|
||||||
isFilterActive,
|
isFilterActive,
|
||||||
openDetailPanel,
|
openDetailPanel,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
@@ -4715,9 +4599,9 @@ const AppLayout = () => {
|
|||||||
currentTenantId,
|
currentTenantId,
|
||||||
folderClickHandlers,
|
folderClickHandlers,
|
||||||
folderNodes,
|
folderNodes,
|
||||||
handleFolderDelete,
|
|
||||||
handleFolderDragEnd,
|
handleFolderDragEnd,
|
||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
|
handleFolderDelete,
|
||||||
handleFolderRename,
|
handleFolderRename,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
@@ -4830,9 +4714,9 @@ const AppLayout = () => {
|
|||||||
if (!rowKey) {
|
if (!rowKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleRowSelection(rowKey, event);
|
handleEntrySelection(rowKey, event);
|
||||||
},
|
},
|
||||||
[handleRowSelection],
|
[handleEntrySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeskDocumentStackSelect = useCallback(
|
const handleDeskDocumentStackSelect = useCallback(
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const useDocumentSelection = ({
|
|||||||
applySelection([], { anchor: null, interactedKeys: [] });
|
applySelection([], { anchor: null, interactedKeys: [] });
|
||||||
}, [applySelection]);
|
}, [applySelection]);
|
||||||
|
|
||||||
const handleRowSelection = useCallback(
|
const handleEntrySelection = useCallback(
|
||||||
(rowKey, event) => {
|
(rowKey, event) => {
|
||||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||||
const navigableRowKeys = navigableRowKeysRef.current;
|
const navigableRowKeys = navigableRowKeysRef.current;
|
||||||
@@ -241,7 +241,7 @@ export const useDocumentSelection = ({
|
|||||||
setFocusedRowKey,
|
setFocusedRowKey,
|
||||||
applySelection,
|
applySelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
handleRowSelection,
|
handleEntrySelection,
|
||||||
promoteSelectionOrder,
|
promoteSelectionOrder,
|
||||||
configureSelectionEnvironment,
|
configureSelectionEnvironment,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FolderIcon } from '../ui/icons';
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
import CorrespondentLinks from './CorrespondentLinks';
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { resolveCorrespondents } from './correspondents';
|
import { resolveCorrespondents } from './correspondents';
|
||||||
import { writeTagTransferData } from './tagTransfer';
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
|
import useInlineRename from './useInlineRename';
|
||||||
|
|
||||||
const DocumentsGrid = ({
|
const DocumentsGrid = ({
|
||||||
entries,
|
entries,
|
||||||
@@ -35,7 +36,42 @@ const DocumentsGrid = ({
|
|||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
onClearSelection,
|
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
|
<div
|
||||||
className="documents-grid"
|
className="documents-grid"
|
||||||
role="list"
|
role="list"
|
||||||
@@ -58,6 +94,14 @@ const DocumentsGrid = ({
|
|||||||
const classes = ['document-card', 'folder-card'];
|
const classes = ['document-card', 'folder-card'];
|
||||||
if (isDraggingFolder) classes.push('is-dragging');
|
if (isDraggingFolder) classes.push('is-dragging');
|
||||||
if (isSelectedFolder) classes.push('selected');
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -89,9 +133,83 @@ const DocumentsGrid = ({
|
|||||||
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||||
</div>
|
</div>
|
||||||
<div className="folder-card__meta">
|
<div className="folder-card__meta">
|
||||||
<div className="folder-card__name" title={folder.name}>
|
{isFolderEditing ? (
|
||||||
{folder.name}
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -111,6 +229,13 @@ const DocumentsGrid = ({
|
|||||||
const cardClasses = ['document-card', 'document'];
|
const cardClasses = ['document-card', 'document'];
|
||||||
if (isSelected) cardClasses.push('selected');
|
if (isSelected) cardClasses.push('selected');
|
||||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
@@ -149,7 +274,82 @@ const DocumentsGrid = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
{visibleTags.length > 0 && (
|
{visibleTags.length > 0 && (
|
||||||
<div className="document-card__tags">
|
<div className="document-card__tags">
|
||||||
@@ -205,5 +405,6 @@ const DocumentsGrid = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DocumentsGrid;
|
export default DocumentsGrid;
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||||
FolderIcon,
|
|
||||||
EditIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from '../ui/icons';
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
import CorrespondentLinks from './CorrespondentLinks';
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
import { resolveCorrespondents } from './correspondents';
|
import { resolveCorrespondents } from './correspondents';
|
||||||
import { writeTagTransferData } from './tagTransfer';
|
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 = ({
|
const DocumentsList = ({
|
||||||
entries,
|
entries,
|
||||||
@@ -20,7 +27,6 @@ const DocumentsList = ({
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
getDownloadHref,
|
|
||||||
onFolderClick,
|
onFolderClick,
|
||||||
onFolderSelect,
|
onFolderSelect,
|
||||||
onFolderDragOver,
|
onFolderDragOver,
|
||||||
@@ -29,7 +35,6 @@ const DocumentsList = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
onFolderDelete,
|
|
||||||
onDocumentClick,
|
onDocumentClick,
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
@@ -38,20 +43,53 @@ const DocumentsList = ({
|
|||||||
onDocumentTagDragLeave,
|
onDocumentTagDragLeave,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
onDocumentDelete,
|
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeCorrespondentIdSet,
|
activeCorrespondentIdSet,
|
||||||
scrollRef,
|
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">
|
<table aria-multiselectable="true">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="thumb-column"></th>
|
<th className="thumb-column"></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Issued</th>
|
<th>Issued</th>
|
||||||
<th>Actions</th>
|
<th>Added</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -65,6 +103,14 @@ const DocumentsList = ({
|
|||||||
const isDraggingFolder = draggedFolderId === folder.id;
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||||
const rowKey = `folder:${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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -100,48 +146,90 @@ const DocumentsList = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className="doc-list__name">
|
<td className="doc-list__name">
|
||||||
<div className="doc-list__name-content">
|
<div className="doc-list__name-content">
|
||||||
<span>{folder.name}</span>
|
<span className="doc-name__title">
|
||||||
</div>
|
<span className="doc-name__primary">
|
||||||
</td>
|
{isFolderEditing ? (
|
||||||
<td></td>
|
<span className="doc-title-edit">
|
||||||
<td className="actions">
|
<input
|
||||||
<div className="action-buttons">
|
type="text"
|
||||||
{folder.id !== 'root' && onFolderRename && (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
title="Rename"
|
aria-label="Save name"
|
||||||
aria-label={`Rename folder ${folder.name}`}
|
title="Save name"
|
||||||
|
disabled={!canSubmitFolder || isFolderSaving}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const nextName = window.prompt('Rename folder', folder.name);
|
submitFolderEditing(folder);
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === folder.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onFolderRename?.(folder.id, trimmed);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon className="icon-inline" />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button danger"
|
className="icon-button"
|
||||||
title="Delete"
|
aria-label="Cancel"
|
||||||
aria-label={`Delete folder ${folder.name}`}
|
title="Cancel"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
cancelFolderEditing(event);
|
||||||
onFolderDelete?.(folder.id);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-inline" />
|
<CloseIcon />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,8 +243,17 @@ const DocumentsList = ({
|
|||||||
const rowClasses = ['document'];
|
const rowClasses = ['document'];
|
||||||
if (isSelected) rowClasses.push('selected');
|
if (isSelected) rowClasses.push('selected');
|
||||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||||
const downloadHref = getDownloadHref?.(doc) || null;
|
|
||||||
const correspondents = resolveCorrespondents(doc);
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
@@ -194,7 +291,84 @@ const DocumentsList = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
@@ -244,79 +418,14 @@ const DocumentsList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>{issuedLabel}</td>
|
||||||
{(() => {
|
<td>{addedLabel}</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DocumentsList;
|
export default DocumentsList;
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const DocumentsPanel = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onFolderDelete,
|
|
||||||
onFolderRename,
|
onFolderRename,
|
||||||
selectedFolderIds = [],
|
selectedFolderIds = [],
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
@@ -45,9 +44,8 @@ const DocumentsPanel = ({
|
|||||||
draggingDocumentIds = [],
|
draggingDocumentIds = [],
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
onDocumentDragEnd,
|
onDocumentDragEnd,
|
||||||
onDocumentDelete,
|
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
onRowSelection = null,
|
onEntrySelection = null,
|
||||||
onOpenDetailPanel = null,
|
onOpenDetailPanel = null,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
activeCorrespondentIds = [],
|
activeCorrespondentIds = [],
|
||||||
@@ -56,7 +54,6 @@ const DocumentsPanel = ({
|
|||||||
onFocusedRowChange,
|
onFocusedRowChange,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
getDownloadHref,
|
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
@@ -253,8 +250,8 @@ const DocumentsPanel = ({
|
|||||||
|
|
||||||
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
|
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
|
||||||
|
|
||||||
if (rowKey && typeof onRowSelection === 'function') {
|
if (rowKey && typeof onEntrySelection === 'function') {
|
||||||
onRowSelection(rowKey, event);
|
onEntrySelection(rowKey, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === EntryType.document) {
|
if (entry.type === EntryType.document) {
|
||||||
@@ -281,7 +278,7 @@ const DocumentsPanel = ({
|
|||||||
onFocusedRowChange?.(rowKey);
|
onFocusedRowChange?.(rowKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
|
[onEntrySelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentClick = useCallback(
|
const handleDocumentClick = useCallback(
|
||||||
@@ -472,6 +469,8 @@ const DocumentsPanel = ({
|
|||||||
onCorrespondentClick={onCorrespondentClick}
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
onClearSelection={onClearSelection}
|
onClearSelection={onClearSelection}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
/>
|
/>
|
||||||
) : !showTableRows ? null : (
|
) : !showTableRows ? null : (
|
||||||
<DocumentsList
|
<DocumentsList
|
||||||
@@ -483,7 +482,6 @@ const DocumentsPanel = ({
|
|||||||
draggedFolderId={draggedFolderId}
|
draggedFolderId={draggedFolderId}
|
||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
getDownloadHref={getDownloadHref}
|
|
||||||
onFolderClick={handleFolderClick}
|
onFolderClick={handleFolderClick}
|
||||||
onFolderSelect={onFolderSelect}
|
onFolderSelect={onFolderSelect}
|
||||||
onFolderDragOver={onFolderDragOver}
|
onFolderDragOver={onFolderDragOver}
|
||||||
@@ -492,7 +490,6 @@ const DocumentsPanel = ({
|
|||||||
onFolderDragStart={onFolderDragStart}
|
onFolderDragStart={onFolderDragStart}
|
||||||
onFolderDragEnd={onFolderDragEnd}
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
onFolderRename={onFolderRename}
|
onFolderRename={onFolderRename}
|
||||||
onFolderDelete={onFolderDelete}
|
|
||||||
onDocumentClick={handleDocumentClick}
|
onDocumentClick={handleDocumentClick}
|
||||||
onDocumentOpen={onDocumentOpen}
|
onDocumentOpen={onDocumentOpen}
|
||||||
onDocumentDragStart={handleDocumentDragStartLocal}
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
@@ -501,7 +498,6 @@ const DocumentsPanel = ({
|
|||||||
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
onDocumentTagDrop={handleDocumentTagDrop}
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
onDocumentRename={onDocumentRename}
|
onDocumentRename={onDocumentRename}
|
||||||
onDocumentDelete={onDocumentDelete}
|
|
||||||
tagLookupById={tagLookupById}
|
tagLookupById={tagLookupById}
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
onCorrespondentClick={onCorrespondentClick}
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
|||||||
@@ -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
@@ -2168,6 +2168,43 @@ button.danger:hover:not([disabled]) {
|
|||||||
width: 100%;
|
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 {
|
.doc-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2294,16 +2331,31 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-card__title {
|
.document-card__title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
color: var(--fg);
|
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;
|
padding: 0.2rem 0.7rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: var(--documents-grid-title-size);
|
font-size: var(--documents-grid-title-size);
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card.selected .document-card__title,
|
.document-card.selected .document-card__title-badge {
|
||||||
.folder-card.selected .folder-card__name {
|
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
@@ -2346,6 +2398,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
padding-top: 0.35rem;
|
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 {
|
.folder-card__name {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2356,6 +2416,18 @@ button.danger:hover:not([disabled]) {
|
|||||||
border-radius: 1rem;
|
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 {
|
.doc-name__title {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user