refactor: Decompose document and folder views into modular components and hooks, replacing monolithic list/grid implementations.
This commit is contained in:
@@ -1,441 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
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, parseTagTransferPayload } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
|
||||
import type {
|
||||
Folder as FolderLike,
|
||||
Document,
|
||||
} from '../types/documents';
|
||||
import type { DocumentsViewProps } from './panel/DocumentsPanel';
|
||||
|
||||
interface DocumentsGridProps extends DocumentsViewProps {
|
||||
gridIconSize?: number;
|
||||
}
|
||||
|
||||
const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
entries,
|
||||
draggingDocumentIdsSet,
|
||||
draggedFolderId,
|
||||
onFolderClick,
|
||||
onFolderSelect,
|
||||
onFolderDragOver,
|
||||
onFolderDragLeave,
|
||||
onFolderDrop,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
onDocumentClick,
|
||||
onDocumentActivate,
|
||||
onDocumentDragStart,
|
||||
onDocumentDragEnd,
|
||||
onDocumentTagDragOver,
|
||||
onDocumentTagDragLeave,
|
||||
onDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
gridIconSize,
|
||||
tagLookupById,
|
||||
onTagClick,
|
||||
scrollRef,
|
||||
onCorrespondentClick,
|
||||
activeCorrespondentIdSet,
|
||||
onDocumentRename,
|
||||
onFolderRename,
|
||||
}) => {
|
||||
const {
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
clearSelection,
|
||||
} = useWorkspaceSelectionContext();
|
||||
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
|
||||
const selectedFolderIdsSet = useMemo(() => new Set(selectedFolderIds || []), [selectedFolderIds]);
|
||||
const {
|
||||
editingId: editingDocumentId,
|
||||
draftValue: documentDraft,
|
||||
setDraftValue: setDocumentDraft,
|
||||
beginEditing: beginDocumentEditing,
|
||||
cancelEditing: cancelDocumentEditing,
|
||||
submitEditing: submitDocumentEditing,
|
||||
savingId: savingDocumentId,
|
||||
attachInputRef: attachDocumentInputRef,
|
||||
} = useInlineRename<Document>(onDocumentRename, {
|
||||
getCurrentValue: (doc: Document) => doc?.title ?? '',
|
||||
getEntityId: (doc: Document) => doc?.id ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
editingId: editingFolderId,
|
||||
draftValue: folderDraft,
|
||||
setDraftValue: setFolderDraft,
|
||||
beginEditing: beginFolderEditing,
|
||||
cancelEditing: cancelFolderEditing,
|
||||
submitEditing: submitFolderEditing,
|
||||
savingId: savingFolderId,
|
||||
attachInputRef: attachFolderInputRef,
|
||||
} = useInlineRename<FolderLike>(onFolderRename, {
|
||||
getCurrentValue: (folder: FolderLike) => folder?.name ?? '',
|
||||
getEntityId: (folder: FolderLike) => folder?.id ?? null,
|
||||
});
|
||||
|
||||
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
||||
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
|
||||
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const classes = ['document-card', 'folder-card'];
|
||||
if (isDraggingFolder) classes.push('is-dragging');
|
||||
if (isSelectedFolder) classes.push('selected');
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
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
|
||||
key={entry.key}
|
||||
className={classes.join(' ')}
|
||||
role="listitem"
|
||||
id={`folder-card-${folder.id}`}
|
||||
draggable={canDragFolder}
|
||||
onClick={(event) => onFolderClick?.(folder, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="folder-card__icon">
|
||||
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
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}
|
||||
className={cardClasses.join(' ')}
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||
onDragOver={(event) => onDocumentTagDragOver?.(event)}
|
||||
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
|
||||
onDragLeave={onDocumentTagDragLeave}
|
||||
onDragLeaveCapture={onDocumentTagDragLeave}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (payload && onDocumentTagDrop) {
|
||||
onDocumentTagDrop(doc.id, payload);
|
||||
}
|
||||
}}
|
||||
|
||||
>
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
maxSize={gridIconSize}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div className="document-card__title" title={doc.title}>
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{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">
|
||||
{visibleTags.map((tag, index) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
const tagId = tag?.id ?? null;
|
||||
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
if (tagId == null) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
} : undefined}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to configure drag effect', error);
|
||||
}
|
||||
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (tagId == null) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{remainingTagCount > 0 && (
|
||||
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsGrid;
|
||||
@@ -1,449 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatDate } from '../utils/date';
|
||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||
import CorrespondentLinks from './CorrespondentLinks';
|
||||
import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData, parseTagTransferPayload } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
|
||||
import type { DocumentsViewProps } from './panel/DocumentsPanel';
|
||||
import type {
|
||||
Document,
|
||||
Folder,
|
||||
} from '../types/documents';
|
||||
|
||||
const DocumentsList: React.FC<DocumentsViewProps> = ({
|
||||
entries,
|
||||
draggingDocumentIdsSet,
|
||||
draggedFolderId,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
onFolderClick,
|
||||
onFolderSelect,
|
||||
onFolderDragOver,
|
||||
onFolderDragLeave,
|
||||
onFolderDrop,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
onFolderRename,
|
||||
onDocumentClick,
|
||||
onDocumentActivate,
|
||||
onDocumentDragStart,
|
||||
onDocumentDragEnd,
|
||||
onDocumentTagDragOver,
|
||||
onDocumentTagDragLeave,
|
||||
onDocumentTagDrop,
|
||||
onDocumentRename,
|
||||
tagLookupById,
|
||||
onTagClick,
|
||||
onCorrespondentClick,
|
||||
activeCorrespondentIdSet,
|
||||
scrollRef,
|
||||
}) => {
|
||||
const {
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
clearSelection,
|
||||
} = useWorkspaceSelectionContext();
|
||||
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
|
||||
const selectedFolderIdsSet = useMemo(
|
||||
() => new Set(selectedFolderIds || []),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
const {
|
||||
editingId: editingDocumentId,
|
||||
draftValue: documentDraft,
|
||||
setDraftValue: setDocumentDraft,
|
||||
beginEditing: beginDocumentEditing,
|
||||
cancelEditing: cancelDocumentEditing,
|
||||
submitEditing: submitDocumentEditing,
|
||||
savingId: savingDocumentId,
|
||||
attachInputRef: attachDocumentInputRef,
|
||||
} = useInlineRename<Document>(onDocumentRename, {
|
||||
getCurrentValue: (doc: Document) => doc?.title ?? '',
|
||||
getEntityId: (doc: Document) => doc?.id ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
editingId: editingFolderId,
|
||||
draftValue: folderDraft,
|
||||
setDraftValue: setFolderDraft,
|
||||
beginEditing: beginFolderEditing,
|
||||
cancelEditing: cancelFolderEditing,
|
||||
submitEditing: submitFolderEditing,
|
||||
savingId: savingFolderId,
|
||||
attachInputRef: attachFolderInputRef,
|
||||
} = useInlineRename<Folder>(onFolderRename, {
|
||||
getCurrentValue: (folder: Folder) => folder?.name ?? '',
|
||||
getEntityId: (folder: Folder) => folder?.id ?? null,
|
||||
});
|
||||
|
||||
|
||||
const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0;
|
||||
const folderSelectionCount = selectedFolderIdsSet?.size ?? 0;
|
||||
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||
|
||||
return (
|
||||
<table aria-multiselectable="true">
|
||||
<thead
|
||||
onClick={() => {
|
||||
clearSelection();
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Name</th>
|
||||
<th>Issued</th>
|
||||
<th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
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
|
||||
key={entry.key}
|
||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${isSelectedFolder ? ' selected' : ''}`}
|
||||
id={`folder-row-${folder.id}`}
|
||||
onClick={(event) => onFolderClick?.(folder, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||
draggable={canDragFolder}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<div className="thumb-icon">
|
||||
<FolderIcon className="thumb-icon__image" size={32} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-list__name-content">
|
||||
<span 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>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
const rowClasses = ['document'];
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const 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}
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
|
||||
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||
onDragOver={onDocumentTagDragOver}
|
||||
onDragLeave={onDocumentTagDragLeave}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (payload && onDocumentTagDrop) {
|
||||
onDocumentTagDrop(doc.id, payload);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="doc-name__primary">
|
||||
{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 && (
|
||||
<div className="doc-name__tags">
|
||||
{(doc.tags || []).map((tag, index) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
const tagId = tag?.id ?? null;
|
||||
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
if (tagId == null) return;
|
||||
onTagClick?.(tagId);
|
||||
} : undefined}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to configure drag effect', error);
|
||||
}
|
||||
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (tagId == null) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{issuedLabel}</td>
|
||||
<td>{addedLabel}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsList;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useDocumentViewLogic, DocumentViewLogic } from './hooks/useDocumentViewLogic';
|
||||
import DocumentsListRow from './components/DocumentsListRow';
|
||||
import DocumentsGridCard from './components/DocumentsGridCard';
|
||||
import DocumentsListContainer from './components/DocumentsListContainer';
|
||||
import DocumentsGridContainer from './components/DocumentsGridContainer';
|
||||
import type { DocumentsViewProps } from './panel/DocumentsPanel';
|
||||
|
||||
interface AbstractDocumentsViewProps<CProps extends { clearSelection: () => void; children: React.ReactNode }> extends DocumentsViewProps {
|
||||
ContainerComponent: React.ComponentType<CProps>;
|
||||
ItemComponent: React.ComponentType<{ entry: any; viewLogic: DocumentViewLogic } & DocumentsViewProps>;
|
||||
containerProps?: Omit<CProps, 'children' | 'clearSelection'>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const AbstractDocumentsView = <CProps extends { clearSelection: () => void; children: React.ReactNode }>({
|
||||
ContainerComponent,
|
||||
ItemComponent,
|
||||
containerProps,
|
||||
...props
|
||||
}: AbstractDocumentsViewProps<CProps>) => {
|
||||
const { entries, onDocumentRename, onFolderRename } = props;
|
||||
const viewLogic = useDocumentViewLogic({
|
||||
onDocumentRename,
|
||||
onFolderRename,
|
||||
});
|
||||
const { clearSelection } = viewLogic;
|
||||
|
||||
return (
|
||||
<ContainerComponent clearSelection={clearSelection} {...(containerProps as any)}>
|
||||
{entries.map((entry) => (
|
||||
<ItemComponent
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
viewLogic={viewLogic}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</ContainerComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentsList: React.FC<DocumentsViewProps> = (props) => {
|
||||
return (
|
||||
<AbstractDocumentsView
|
||||
ContainerComponent={DocumentsListContainer}
|
||||
ItemComponent={DocumentsListRow}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentsGrid: React.FC<DocumentsViewProps & { gridIconSize?: number }> = (props) => {
|
||||
return (
|
||||
<AbstractDocumentsView
|
||||
ContainerComponent={DocumentsGridContainer}
|
||||
ItemComponent={DocumentsGridCard}
|
||||
containerProps={{ gridIconSize: props.gridIconSize }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
|
||||
import { useDocumentItemLogic } from '../hooks/useDocumentItemLogic';
|
||||
import EntryShell from './EntryShell';
|
||||
|
||||
interface DocumentEntryProps extends DocumentsViewProps {
|
||||
doc: any;
|
||||
viewLogic: DocumentViewLogic;
|
||||
component: React.ElementType;
|
||||
className?: string;
|
||||
role?: string;
|
||||
children: (logic: ReturnType<typeof useDocumentItemLogic>) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DocumentEntry: React.FC<DocumentEntryProps> = (props) => {
|
||||
const { doc, component, className, role, children, viewLogic } = props;
|
||||
const logic = useDocumentItemLogic({ doc, viewLogic, ...props });
|
||||
|
||||
return (
|
||||
<EntryShell
|
||||
component={component}
|
||||
id={`document-${doc.id}`}
|
||||
docId={doc.id}
|
||||
handlers={logic.handlers}
|
||||
isSelected={logic.isSelected}
|
||||
isDragging={logic.isDraggingDoc}
|
||||
canDrag={true}
|
||||
className={className}
|
||||
role={role}
|
||||
>
|
||||
{children(logic)}
|
||||
</EntryShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentEntry;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { getTagColorStyle } from '../../utils/colors';
|
||||
import { writeTagTransferData } from '../tagTransfer';
|
||||
import type { DocumentTag } from '../../types/documents';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface DocumentTagsProps {
|
||||
tags: DocumentTag[];
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
docId: Identifier;
|
||||
maxTags?: number;
|
||||
}
|
||||
|
||||
const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||
tags,
|
||||
tagLookupById,
|
||||
onTagClick,
|
||||
docId,
|
||||
maxTags,
|
||||
}) => {
|
||||
const visibleTags = maxTags ? tags.slice(0, maxTags) : tags;
|
||||
const remainingTagCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
|
||||
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleTags.map((tag, index) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
const tagId = tag?.id ?? null;
|
||||
const clickable = tagId != null && typeof onTagClick === 'function';
|
||||
const key = tagId ?? `${docId}-tag-${index}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
if (tagId == null) return;
|
||||
onTagClick?.(tagId);
|
||||
} : undefined}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[documents] Failed to configure drag effect', error);
|
||||
}
|
||||
writeTagTransferData(event.dataTransfer, tag, docId);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (tagId == null) return;
|
||||
onTagClick?.(tagId);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{remainingTagCount > 0 && (
|
||||
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentTags;
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { FolderIcon } from '../../ui/icons';
|
||||
import DocumentThumbnailImage from '../DocumentThumbnailImage';
|
||||
import { resolveCorrespondents } from '../correspondents';
|
||||
import type { DocumentsListEntry } from '../../types/documents';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
|
||||
import EditableEntryTitle from './EditableEntryTitle';
|
||||
import EntryCorrespondents from './EntryCorrespondents';
|
||||
import EntryTags from './EntryTags';
|
||||
import FolderEntry from './FolderEntry';
|
||||
import DocumentEntry from './DocumentEntry';
|
||||
|
||||
interface DocumentsGridCardProps extends DocumentsViewProps {
|
||||
entry: DocumentsListEntry;
|
||||
viewLogic: DocumentViewLogic;
|
||||
gridIconSize?: number;
|
||||
}
|
||||
|
||||
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
|
||||
const { entry, gridIconSize } = props;
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<FolderEntry
|
||||
{...props}
|
||||
folder={folder}
|
||||
component="div"
|
||||
className="document-card folder-card"
|
||||
role="listitem"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<div className="folder-card__icon">
|
||||
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
<div className="folder-card__label-row">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isFolderEditing}
|
||||
draftValue={logic.folderDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isFolderSaving}
|
||||
canSubmit={logic.canSubmitFolder}
|
||||
inputRef={logic.attachFolderInputRef}
|
||||
allowInlineEdit={logic.allowInlineFolderEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="folder-card__name"
|
||||
>
|
||||
{folder.name}
|
||||
</EditableEntryTitle>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FolderEntry>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) return null;
|
||||
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
|
||||
return (
|
||||
<DocumentEntry
|
||||
{...props}
|
||||
doc={doc}
|
||||
component="div"
|
||||
className="document-card document"
|
||||
role="listitem"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={props.ensureAssetUrl}
|
||||
getDocumentAsset={props.getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
maxSize={gridIconSize}
|
||||
scrollRootRef={props.scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div className="document-card__title" title={doc.title}>
|
||||
<EntryCorrespondents
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
|
||||
onCorrespondentClick={props.onCorrespondentClick}
|
||||
/>
|
||||
<div className="document-card__title-row">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isEditingDoc}
|
||||
draftValue={logic.documentDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isDocumentSaving}
|
||||
canSubmit={logic.canSubmitDocument}
|
||||
inputRef={logic.attachDocumentInputRef}
|
||||
allowInlineEdit={logic.allowInlineDocumentEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="document-card__title-badge"
|
||||
>
|
||||
{doc.title}
|
||||
</EditableEntryTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-card__tags">
|
||||
<EntryTags
|
||||
tags={doc.tags || []}
|
||||
tagLookupById={props.tagLookupById}
|
||||
onTagClick={props.onTagClick}
|
||||
docId={doc.id}
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DocumentEntry>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsGridCard;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DocumentsGridContainerProps {
|
||||
children: React.ReactNode;
|
||||
clearSelection: () => void;
|
||||
gridIconSize?: number;
|
||||
}
|
||||
|
||||
const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({
|
||||
children,
|
||||
clearSelection,
|
||||
gridIconSize,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
style={
|
||||
gridIconSize
|
||||
? ({ '--documents-grid-icon-size': `${gridIconSize}px` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsGridContainer;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DocumentsListContainerProps {
|
||||
children: React.ReactNode;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({
|
||||
children,
|
||||
clearSelection,
|
||||
}) => {
|
||||
return (
|
||||
<table aria-multiselectable="true">
|
||||
<thead
|
||||
onClick={() => {
|
||||
clearSelection();
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Name</th>
|
||||
<th>Issued</th>
|
||||
<th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsListContainer;
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { FolderIcon } from '../../ui/icons';
|
||||
import { formatDate } from '../../utils/date';
|
||||
import DocumentThumbnailImage from '../DocumentThumbnailImage';
|
||||
import { resolveCorrespondents } from '../correspondents';
|
||||
import type { DocumentsListEntry } from '../../types/documents';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
|
||||
import EditableEntryTitle from './EditableEntryTitle';
|
||||
import EntryCorrespondents from './EntryCorrespondents';
|
||||
import EntryTags from './EntryTags';
|
||||
import FolderEntry from './FolderEntry';
|
||||
import DocumentEntry from './DocumentEntry';
|
||||
|
||||
interface DocumentsListRowProps extends DocumentsViewProps {
|
||||
entry: DocumentsListEntry;
|
||||
viewLogic: DocumentViewLogic;
|
||||
}
|
||||
|
||||
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
||||
const { entry } = props;
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<FolderEntry
|
||||
{...props}
|
||||
folder={folder}
|
||||
component="tr"
|
||||
className="folder"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<td className="thumb-cell">
|
||||
<div className="thumb-icon">
|
||||
<FolderIcon className="thumb-icon__image" size={32} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
<span className="doc-name__primary">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isFolderEditing}
|
||||
draftValue={logic.folderDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isFolderSaving}
|
||||
canSubmit={logic.canSubmitFolder}
|
||||
inputRef={logic.attachFolderInputRef}
|
||||
allowInlineEdit={logic.allowInlineFolderEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="doc-name__primary-text"
|
||||
>
|
||||
{folder.name}
|
||||
</EditableEntryTitle>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</>
|
||||
)}
|
||||
</FolderEntry>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) return null;
|
||||
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const issuedLabel = formatDate(doc.issued_at);
|
||||
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
|
||||
|
||||
return (
|
||||
<DocumentEntry
|
||||
{...props}
|
||||
doc={doc}
|
||||
component="tr"
|
||||
className="document"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<td className="thumb-cell">
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={props.ensureAssetUrl}
|
||||
getDocumentAsset={props.getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
scrollRootRef={props.scrollRef}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
<EntryCorrespondents
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
|
||||
onCorrespondentClick={props.onCorrespondentClick}
|
||||
/>
|
||||
<span className="doc-name__primary">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isEditingDoc}
|
||||
draftValue={logic.documentDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isDocumentSaving}
|
||||
canSubmit={logic.canSubmitDocument}
|
||||
inputRef={logic.attachDocumentInputRef}
|
||||
allowInlineEdit={logic.allowInlineDocumentEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="doc-name__primary-text"
|
||||
>
|
||||
{doc.title}
|
||||
</EditableEntryTitle>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="doc-name__tags">
|
||||
<EntryTags
|
||||
tags={doc.tags || []}
|
||||
tagLookupById={props.tagLookupById}
|
||||
onTagClick={props.onTagClick}
|
||||
docId={doc.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{issuedLabel}</td>
|
||||
<td>{addedLabel}</td>
|
||||
</>
|
||||
)}
|
||||
</DocumentEntry>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsListRow;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import InlineRenameInput from './InlineRenameInput';
|
||||
|
||||
interface EditableEntryTitleProps {
|
||||
isEditing: boolean;
|
||||
draftValue: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: (event?: React.SyntheticEvent) => void;
|
||||
isSaving: boolean;
|
||||
canSubmit: boolean;
|
||||
inputRef: (ref: HTMLInputElement | null) => void;
|
||||
allowInlineEdit: boolean | undefined;
|
||||
onBeginEditing: (event: React.SyntheticEvent) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EditableEntryTitle: React.FC<EditableEntryTitleProps> = ({
|
||||
isEditing,
|
||||
draftValue,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSaving,
|
||||
canSubmit,
|
||||
inputRef,
|
||||
allowInlineEdit,
|
||||
onBeginEditing,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`doc-title-edit ${className || ''}`}>
|
||||
<InlineRenameInput
|
||||
value={draftValue}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
isSaving={isSaving}
|
||||
canSubmit={canSubmit}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role={allowInlineEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineEdit ? 0 : undefined}
|
||||
onClick={onBeginEditing}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineEdit) return;
|
||||
if (event.key === 'Enter') {
|
||||
onBeginEditing(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableEntryTitle;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import CorrespondentLinks from '../CorrespondentLinks';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface EntryCorrespondentsProps {
|
||||
correspondents: any[];
|
||||
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
||||
}
|
||||
|
||||
const EntryCorrespondents: React.FC<EntryCorrespondentsProps> = (props) => {
|
||||
if (!props.correspondents || props.correspondents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={props.correspondents}
|
||||
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
|
||||
onCorrespondentClick={props.onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryCorrespondents;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { type DragEvent } from 'react';
|
||||
|
||||
export interface EntryShellHandlers {
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
onDoubleClick: (event: React.MouseEvent) => void;
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragEnd: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragOverCapture?: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragLeaveCapture?: (event: DragEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
interface EntryShellProps {
|
||||
component: React.ElementType;
|
||||
handlers: EntryShellHandlers;
|
||||
isSelected?: boolean;
|
||||
isDragging?: boolean;
|
||||
canDrag?: boolean;
|
||||
id: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
docId?: number;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const EntryShell: React.FC<EntryShellProps> = ({
|
||||
component: Component,
|
||||
handlers,
|
||||
isSelected,
|
||||
isDragging,
|
||||
canDrag,
|
||||
id,
|
||||
className = '',
|
||||
children,
|
||||
docId,
|
||||
role,
|
||||
}) => {
|
||||
const classes = [className];
|
||||
if (isSelected) classes.push('selected');
|
||||
if (isDragging) classes.push('is-dragging');
|
||||
|
||||
const commonProps = {
|
||||
id,
|
||||
className: classes.join(' '),
|
||||
onClick: handlers.onClick,
|
||||
onDoubleClick: handlers.onDoubleClick,
|
||||
draggable: canDrag,
|
||||
onDragStart: handlers.onDragStart,
|
||||
onDragEnd: handlers.onDragEnd,
|
||||
onDragOver: handlers.onDragOver,
|
||||
onDragLeave: handlers.onDragLeave,
|
||||
onDrop: handlers.onDrop,
|
||||
...(docId ? { 'data-doc-id': docId } : {}),
|
||||
...(role ? { role } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Component {...commonProps}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryShell;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import DocumentTags from './DocumentTags';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { DocumentTag } from '../../types/documents';
|
||||
|
||||
interface EntryTagsProps {
|
||||
tags: Identifier[];
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId: Identifier) => void;
|
||||
docId: Identifier;
|
||||
maxTags?: number;
|
||||
}
|
||||
|
||||
const EntryTags: React.FC<EntryTagsProps> = (props) => {
|
||||
if (!props.tags || props.tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentTags
|
||||
tags={props.tags}
|
||||
tagLookupById={props.tagLookupById}
|
||||
onTagClick={props.onTagClick}
|
||||
docId={props.docId}
|
||||
maxTags={props.maxTags}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryTags;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
|
||||
import { useFolderItemLogic } from '../hooks/useFolderItemLogic';
|
||||
import EntryShell from './EntryShell';
|
||||
|
||||
interface FolderEntryProps extends DocumentsViewProps {
|
||||
folder: any;
|
||||
viewLogic: DocumentViewLogic;
|
||||
component: React.ElementType;
|
||||
className?: string;
|
||||
role?: string;
|
||||
children: (logic: ReturnType<typeof useFolderItemLogic>) => React.ReactNode;
|
||||
}
|
||||
|
||||
const FolderEntry: React.FC<FolderEntryProps> = (props) => {
|
||||
const { folder, component, className, role, children, viewLogic } = props;
|
||||
const logic = useFolderItemLogic({ folder, viewLogic, ...props });
|
||||
|
||||
return (
|
||||
<EntryShell
|
||||
component={component}
|
||||
id={`folder-${folder.id}`}
|
||||
handlers={logic.handlers}
|
||||
isSelected={logic.isSelectedFolder}
|
||||
isDragging={logic.isDraggingFolder}
|
||||
canDrag={logic.canDragFolder}
|
||||
className={className}
|
||||
role={role}
|
||||
>
|
||||
{children(logic)}
|
||||
</EntryShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderEntry;
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { CheckIcon, CloseIcon } from '../../ui/icons';
|
||||
|
||||
interface InlineRenameInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: (event?: React.SyntheticEvent) => void;
|
||||
isSaving?: boolean;
|
||||
canSubmit?: boolean;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InlineRenameInput: React.FC<InlineRenameInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSaving = false,
|
||||
canSubmit = true,
|
||||
inputRef,
|
||||
className = 'doc-title-edit',
|
||||
}) => {
|
||||
return (
|
||||
<span className={className}>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCancel(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save"
|
||||
title="Save"
|
||||
disabled={!canSubmit || isSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => {
|
||||
onCancel(event);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineRenameInput;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { type DragEvent } from 'react';
|
||||
import { parseTagTransferPayload } from '../tagTransfer';
|
||||
import type { Document } from '../../types/documents';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
||||
|
||||
interface UseDocumentItemLogicProps extends DocumentsViewProps {
|
||||
doc: Document;
|
||||
viewLogic: DocumentViewLogic;
|
||||
}
|
||||
|
||||
export const useDocumentItemLogic = (props: UseDocumentItemLogicProps) => {
|
||||
const {
|
||||
doc,
|
||||
viewLogic,
|
||||
draggingDocumentIdsSet,
|
||||
onDocumentClick,
|
||||
onDocumentActivate,
|
||||
onDocumentDragStart,
|
||||
onDocumentDragEnd,
|
||||
onDocumentTagDragOver,
|
||||
onDocumentTagDragLeave,
|
||||
onDocumentTagDrop,
|
||||
onDocumentRename,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
selectedDocumentIdsSet,
|
||||
totalSelectionCount,
|
||||
documentRename: {
|
||||
editingId: editingDocumentId,
|
||||
draftValue: documentDraft,
|
||||
setDraftValue: setDocumentDraft,
|
||||
beginEditing: beginDocumentEditing,
|
||||
cancelEditing: cancelDocumentEditing,
|
||||
submitEditing: submitDocumentEditing,
|
||||
savingId: savingDocumentId,
|
||||
attachInputRef: attachDocumentInputRef,
|
||||
},
|
||||
} = viewLogic;
|
||||
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
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 handlers = {
|
||||
onClick: (event: React.MouseEvent) => onDocumentClick?.(doc, event),
|
||||
onDoubleClick: (event: React.MouseEvent) => onDocumentActivate?.(doc, event),
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc),
|
||||
onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event),
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => onDocumentTagDragOver?.(event),
|
||||
onDragLeave: onDocumentTagDragLeave,
|
||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (payload && onDocumentTagDrop) {
|
||||
onDocumentTagDrop(doc.id, payload);
|
||||
}
|
||||
},
|
||||
onRenameChange: setDocumentDraft,
|
||||
onRenameSubmit: () => submitDocumentEditing(doc),
|
||||
onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event),
|
||||
onRenameBegin: (event: React.SyntheticEvent) => {
|
||||
if (!allowInlineDocumentEdit) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
isSelected,
|
||||
isDraggingDoc,
|
||||
isEditingDoc,
|
||||
documentDraftValue,
|
||||
isDocumentSaving,
|
||||
canSubmitDocument,
|
||||
allowInlineDocumentEdit,
|
||||
attachDocumentInputRef,
|
||||
handlers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
||||
import useInlineRename from '../useInlineRename';
|
||||
import type { Document, Folder } from '../../types/documents';
|
||||
|
||||
interface UseDocumentViewLogicProps {
|
||||
onDocumentRename?: (id: string, name: string) => Promise<boolean> | boolean;
|
||||
onFolderRename?: (id: string, name: string) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
export const useDocumentViewLogic = ({
|
||||
onDocumentRename,
|
||||
onFolderRename,
|
||||
}: UseDocumentViewLogicProps) => {
|
||||
const {
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
clearSelection,
|
||||
} = useWorkspaceSelectionContext();
|
||||
|
||||
const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]);
|
||||
const selectedFolderIdsSet = useMemo(
|
||||
() => new Set(selectedFolderIds || []),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
|
||||
const documentSelectionCount = selectedDocumentIdsSet.size;
|
||||
const folderSelectionCount = selectedFolderIdsSet.size;
|
||||
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||
|
||||
const documentRename = useInlineRename<Document>(onDocumentRename, {
|
||||
getCurrentValue: (doc: Document) => doc?.title ?? '',
|
||||
getEntityId: (doc: Document) => doc?.id ?? null,
|
||||
});
|
||||
|
||||
const folderRename = useInlineRename<Folder>(onFolderRename, {
|
||||
getCurrentValue: (folder: Folder) => folder?.name ?? '',
|
||||
getEntityId: (folder: Folder) => folder?.id ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
selectedDocumentIdsSet,
|
||||
selectedFolderIdsSet,
|
||||
clearSelection,
|
||||
totalSelectionCount,
|
||||
documentRename,
|
||||
folderRename,
|
||||
};
|
||||
};
|
||||
|
||||
export type DocumentViewLogic = ReturnType<typeof useDocumentViewLogic>;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { type DragEvent } from 'react';
|
||||
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
|
||||
import type { DocumentViewLogic } from './useDocumentViewLogic';
|
||||
|
||||
interface UseFolderItemLogicProps extends DocumentsViewProps {
|
||||
folder: any;
|
||||
viewLogic: DocumentViewLogic;
|
||||
}
|
||||
|
||||
export const useFolderItemLogic = (props: UseFolderItemLogicProps) => {
|
||||
const {
|
||||
folder,
|
||||
viewLogic,
|
||||
draggedFolderId,
|
||||
onFolderClick,
|
||||
onFolderSelect,
|
||||
onFolderDragOver,
|
||||
onFolderDragLeave,
|
||||
onFolderDrop,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
onFolderRename,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
selectedFolderIdsSet,
|
||||
totalSelectionCount,
|
||||
folderRename: {
|
||||
editingId: editingFolderId,
|
||||
draftValue: folderDraft,
|
||||
setDraftValue: setFolderDraft,
|
||||
beginEditing: beginFolderEditing,
|
||||
cancelEditing: cancelFolderEditing,
|
||||
submitEditing: submitFolderEditing,
|
||||
savingId: savingFolderId,
|
||||
attachInputRef: attachFolderInputRef,
|
||||
},
|
||||
} = viewLogic;
|
||||
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
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;
|
||||
|
||||
const handlers = {
|
||||
onClick: (event: React.MouseEvent) => onFolderClick?.(folder, event),
|
||||
onDoubleClick: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
},
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => onFolderDragOver?.(event, folder.id),
|
||||
onDragLeave: onFolderDragLeave,
|
||||
onDrop: (event: DragEvent<HTMLElement>) => onFolderDrop?.(event, folder.id),
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
},
|
||||
onDragEnd: (event: DragEvent<HTMLElement>) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
},
|
||||
onRenameChange: setFolderDraft,
|
||||
onRenameSubmit: () => submitFolderEditing(folder),
|
||||
onRenameCancel: (event?: React.SyntheticEvent) => cancelFolderEditing(event),
|
||||
onRenameBegin: (event: React.SyntheticEvent) => {
|
||||
if (!allowInlineFolderEdit) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
canDragFolder,
|
||||
isDraggingFolder,
|
||||
isSelectedFolder,
|
||||
isFolderEditing,
|
||||
folderDraftValue,
|
||||
isFolderSaving,
|
||||
canSubmitFolder,
|
||||
allowInlineFolderEdit,
|
||||
attachFolderInputRef,
|
||||
handlers,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import DocumentsGrid from '../DocumentsGrid';
|
||||
import DocumentsList from '../DocumentsList';
|
||||
import { DocumentsList, DocumentsGrid } from '../DocumentsView';
|
||||
import type { DragEvent, ReactNode, RefObject } from 'react';
|
||||
import type {
|
||||
DocumentsListEntry,
|
||||
@@ -653,9 +652,9 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
if (!container) return;
|
||||
let selector = null;
|
||||
if (focusedEntryKey.startsWith('document:')) {
|
||||
selector = `#document-row-${focusedEntryKey.slice('document:'.length)}`;
|
||||
selector = `#document-${focusedEntryKey.slice('document:'.length)}`;
|
||||
} else if (focusedEntryKey.startsWith('folder:')) {
|
||||
selector = `#folder-row-${focusedEntryKey.slice('folder:'.length)}`;
|
||||
selector = `#folder-${focusedEntryKey.slice('folder:'.length)}`;
|
||||
}
|
||||
if (!selector) {
|
||||
return;
|
||||
@@ -690,10 +689,10 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
const activeDescendantId = useMemo(() => {
|
||||
if (!focusedEntryKey) return undefined;
|
||||
if (focusedEntryKey.startsWith('document:')) {
|
||||
return `document-row-${focusedEntryKey.slice('document:'.length)}`;
|
||||
return `document-${focusedEntryKey.slice('document:'.length)}`;
|
||||
}
|
||||
if (focusedEntryKey.startsWith('folder:')) {
|
||||
return `folder-row-${focusedEntryKey.slice('folder:'.length)}`;
|
||||
return `folder-${focusedEntryKey.slice('folder:'.length)}`;
|
||||
}
|
||||
return undefined;
|
||||
}, [focusedEntryKey]);
|
||||
@@ -785,7 +784,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
[entries],
|
||||
);
|
||||
|
||||
const viewProps: DocumentsViewProps = {
|
||||
const viewProps = {
|
||||
entries,
|
||||
draggingDocumentIdsSet: draggingSet,
|
||||
draggedFolderId,
|
||||
@@ -796,6 +795,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
onFolderDrop,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
onFolderRename,
|
||||
onDocumentClick: handleDocumentClick,
|
||||
onDocumentActivate: handleDocumentActivate,
|
||||
onDocumentDragStart: handleDocumentDragStartLocal,
|
||||
@@ -804,22 +804,17 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
onDocumentTagDragLeave: handleDocumentTagDragLeave,
|
||||
onDocumentTagDrop,
|
||||
onDocumentRename,
|
||||
onFolderRename,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
tagLookupById,
|
||||
onTagClick: toggleTagFilter,
|
||||
onCorrespondentClick: toggleCorrespondentFilter,
|
||||
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
||||
activeTagFilters,
|
||||
scrollRef,
|
||||
// Desk specific
|
||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
activeCorrespondentIdSet: activeCorrespondentIdSet,
|
||||
onCorrespondentClick: toggleCorrespondentFilter,
|
||||
tenantId: currentTenantId,
|
||||
viewId: deskViewId,
|
||||
documentLinks: documentLinkMap,
|
||||
ensureDownloadUrl,
|
||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const useDocumentDragHandlers = ({
|
||||
documentLookup,
|
||||
setDraggedDocumentIds,
|
||||
setDraggedFolderId,
|
||||
documentsViewMode,
|
||||
documentsViewMode: _documentsViewMode,
|
||||
}: UseDocumentDragHandlersOptions) => {
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
const normalizedFolderIds = useMemo(
|
||||
@@ -100,8 +100,7 @@ const useDocumentDragHandlers = ({
|
||||
if (item.type === 'document') {
|
||||
const doc = item.payload;
|
||||
const rowEl = doc?.id
|
||||
? (document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`))
|
||||
? document.getElementById(`document-${doc.id}`)
|
||||
: null;
|
||||
const wrapperEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
|
||||
@@ -144,8 +143,7 @@ const useDocumentDragHandlers = ({
|
||||
const payload = item.payload;
|
||||
const folderId = payload as FolderIdentifier;
|
||||
const rowEl = folderId
|
||||
? (document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`))
|
||||
? document.getElementById(`folder-${folderId}`)
|
||||
: null;
|
||||
const iconEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
|
||||
@@ -211,16 +209,13 @@ const useDocumentDragHandlers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const isGridView = documentsViewMode === 'grid';
|
||||
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||
const selection: Identifier[] = isAlreadySelected
|
||||
? [...selectedDocumentIds]
|
||||
: isGridView
|
||||
? [...selectedDocumentIds, documentId]
|
||||
: [documentId];
|
||||
: [documentId];
|
||||
const folderSelection: FolderIdentifier[] = [];
|
||||
|
||||
if (!isAlreadySelected && !isGridView) {
|
||||
if (!isAlreadySelected) {
|
||||
applySelection([documentKey], {
|
||||
anchor: documentKey,
|
||||
interactedKeys: [documentKey],
|
||||
@@ -271,7 +266,6 @@ const useDocumentDragHandlers = ({
|
||||
createDragPreview,
|
||||
setDraggedFolderId,
|
||||
setDraggedDocumentIds,
|
||||
documentsViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user