628 lines
20 KiB
React
628 lines
20 KiB
React
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
ViewListIcon,
|
|
ViewGridIcon,
|
|
IconFileStack,
|
|
RefreshIcon,
|
|
MinusVerticalIcon,
|
|
} from '../ui/icons';
|
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
|
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
|
import DetailPanel from '../detail/DetailPanel';
|
|
import DocumentsGrid from './DocumentsGrid';
|
|
import DocumentsList from './DocumentsList';
|
|
import { isTagTransferEvent } from './tagTransfer';
|
|
|
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
|
|
|
const EntryType = {
|
|
folder: 'folder',
|
|
document: 'document',
|
|
};
|
|
|
|
const DocumentsPanel = ({
|
|
currentFolderName,
|
|
breadcrumbs,
|
|
onRefresh,
|
|
subfolders,
|
|
documents,
|
|
searchResults,
|
|
isFilterActive = false,
|
|
onFolderSelect,
|
|
onFolderDrop,
|
|
onFolderDragOver,
|
|
onFolderDragLeave,
|
|
onFolderDragStart,
|
|
onFolderDragEnd,
|
|
draggedFolderId,
|
|
onFolderDelete,
|
|
onFolderRename,
|
|
selectedFolderIds = [],
|
|
onDocumentOpen,
|
|
selectedDocumentIds = [],
|
|
focusedRowKey,
|
|
draggingDocumentIds = [],
|
|
onDocumentDragStart,
|
|
onDocumentDragEnd,
|
|
onDocumentDelete,
|
|
onDocumentRename,
|
|
onRowSelection = null,
|
|
onOpenDetailPanel = null,
|
|
tagLookupById,
|
|
activeCorrespondentIds = [],
|
|
onDocumentListFocus,
|
|
onDocumentListKeyDown,
|
|
onFocusedRowChange,
|
|
ensureAssetUrl = null,
|
|
getDocumentAsset = () => null,
|
|
getDownloadHref,
|
|
onTagClick,
|
|
onCorrespondentClick,
|
|
isSearchLoading = false,
|
|
onDocumentTagDrop,
|
|
viewMode = 'list',
|
|
onViewModeChange,
|
|
onClearSelection,
|
|
showHeader = true,
|
|
}) => {
|
|
const showingSearchResults = searchResults !== null;
|
|
const rows = showingSearchResults ? searchResults : documents;
|
|
|
|
const entries = useMemo(() => {
|
|
const list = [];
|
|
if (!showingSearchResults) {
|
|
subfolders.forEach((folder) => {
|
|
if (!folder || !folder.id) {
|
|
return;
|
|
}
|
|
list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
|
|
});
|
|
}
|
|
rows.forEach((doc) => {
|
|
if (!doc || !doc.id) {
|
|
return;
|
|
}
|
|
list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc });
|
|
});
|
|
return list;
|
|
}, [showingSearchResults, subfolders, rows]);
|
|
|
|
const selectedSet = useMemo(
|
|
() => new Set(selectedDocumentIds),
|
|
[selectedDocumentIds],
|
|
);
|
|
const selectedFolderSet = useMemo(
|
|
() => new Set(selectedFolderIds || []),
|
|
[selectedFolderIds],
|
|
);
|
|
const draggingSet = useMemo(
|
|
() => new Set(draggingDocumentIds || []),
|
|
[draggingDocumentIds],
|
|
);
|
|
const activeCorrespondentIdSet = useMemo(
|
|
() => new Set(activeCorrespondentIds || []),
|
|
[activeCorrespondentIds],
|
|
);
|
|
const scrollRef = useRef(null);
|
|
const suppressDocumentClickRef = useRef(false);
|
|
const [, forceVisibilityTick] = useState(0);
|
|
const lastScrollNodeRef = useRef(null);
|
|
const assignScrollRef = useCallback((node) => {
|
|
if (lastScrollNodeRef.current === node) {
|
|
return;
|
|
}
|
|
lastScrollNodeRef.current = node;
|
|
scrollRef.current = node;
|
|
if (node) {
|
|
forceVisibilityTick((value) => value + 1);
|
|
}
|
|
}, []);
|
|
const isGridView = viewMode === 'grid';
|
|
const isDeskView = viewMode === 'desk';
|
|
const isListView = viewMode === 'list';
|
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
|
const handleSetViewMode = useCallback(
|
|
(nextMode) => {
|
|
if (!onViewModeChange) {
|
|
return;
|
|
}
|
|
onViewModeChange(nextMode);
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = 0;
|
|
}
|
|
},
|
|
[onViewModeChange],
|
|
);
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = 0;
|
|
}
|
|
}, [viewMode]);
|
|
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
|
const ensureFocusedRowVisible = useCallback(() => {
|
|
if (!focusedRowKey) return;
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
let selector = null;
|
|
if (focusedRowKey.startsWith('document:')) {
|
|
selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
|
|
} else if (focusedRowKey.startsWith('folder:')) {
|
|
selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
|
}
|
|
if (!selector) {
|
|
return;
|
|
}
|
|
const row = container.querySelector(selector);
|
|
if (!row || !container.contains(row)) {
|
|
return;
|
|
}
|
|
|
|
const header = container.querySelector('thead');
|
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
|
const rowTop = row.offsetTop;
|
|
const rowBottom = rowTop + row.offsetHeight;
|
|
const visibleTop = container.scrollTop + headerHeight;
|
|
const visibleBottom = container.scrollTop + container.clientHeight;
|
|
|
|
if (rowTop < visibleTop) {
|
|
container.scrollTop = Math.max(rowTop - headerHeight, 0);
|
|
return;
|
|
}
|
|
|
|
if (rowBottom > visibleBottom) {
|
|
const nextScrollTop = rowBottom - container.clientHeight;
|
|
container.scrollTop = Math.max(nextScrollTop, 0);
|
|
}
|
|
}, [focusedRowKey]);
|
|
|
|
useEffect(() => {
|
|
ensureFocusedRowVisible();
|
|
}, [ensureFocusedRowVisible]);
|
|
|
|
const activeDescendantId = useMemo(() => {
|
|
if (!focusedRowKey) return undefined;
|
|
if (focusedRowKey.startsWith('document:')) {
|
|
return `document-row-${focusedRowKey.slice('document:'.length)}`;
|
|
}
|
|
if (focusedRowKey.startsWith('folder:')) {
|
|
return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
|
}
|
|
return undefined;
|
|
}, [focusedRowKey]);
|
|
|
|
const handleDocumentTagDragOver = useCallback(
|
|
(event) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
event.currentTarget.classList.add('tag-drop-target');
|
|
},
|
|
[isTagDragEvent],
|
|
);
|
|
|
|
const handleDocumentTagDragLeave = useCallback(
|
|
(event) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
event.currentTarget.classList.remove('tag-drop-target');
|
|
},
|
|
[isTagDragEvent],
|
|
);
|
|
|
|
const handleDocumentTagDrop = useCallback(
|
|
(event, documentId) => {
|
|
if (!isTagDragEvent(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.currentTarget.classList.remove('tag-drop-target');
|
|
const payload =
|
|
event.dataTransfer.getData('application/x-papercrate-tag') ||
|
|
event.dataTransfer.getData('text/papercrate-tag');
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(payload);
|
|
if (parsed?.id && onDocumentTagDrop) {
|
|
onDocumentTagDrop(documentId, parsed);
|
|
}
|
|
} catch (error) {
|
|
console.warn('[documents] Failed to parse tag drop payload', error);
|
|
}
|
|
},
|
|
[isTagDragEvent, onDocumentTagDrop],
|
|
);
|
|
|
|
const handleEntryClick = useCallback(
|
|
(entry, event) => {
|
|
if (!entry || !entry.id) {
|
|
return;
|
|
}
|
|
if (entry.type === EntryType.document && suppressDocumentClickRef.current) {
|
|
return;
|
|
}
|
|
|
|
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
|
|
|
|
if (rowKey && typeof onRowSelection === 'function') {
|
|
onRowSelection(rowKey, event);
|
|
}
|
|
|
|
if (entry.type === EntryType.document) {
|
|
const hasModifier = Boolean(
|
|
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
|
|
);
|
|
if (!hasModifier && typeof onOpenDetailPanel === 'function') {
|
|
onOpenDetailPanel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (entry.type === EntryType.folder) {
|
|
const hasModifier = Boolean(
|
|
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
|
|
);
|
|
const isPrimaryClick = Boolean(event && event.type === 'click' && event.button === 0);
|
|
if (!hasModifier && isPrimaryClick && typeof onFolderSelect === 'function') {
|
|
onFolderSelect(entry.id);
|
|
}
|
|
if (scrollRef.current) {
|
|
scrollRef.current.focus({ preventScroll: true });
|
|
}
|
|
onFocusedRowChange?.(rowKey);
|
|
}
|
|
},
|
|
[onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
|
|
);
|
|
|
|
const handleDocumentClick = useCallback(
|
|
(doc, event) => {
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
handleEntryClick({ type: EntryType.document, id: doc.id, document: doc }, event);
|
|
},
|
|
[handleEntryClick],
|
|
);
|
|
|
|
const handleFolderClick = useCallback(
|
|
(folder, event) => {
|
|
if (!folder) {
|
|
return;
|
|
}
|
|
handleEntryClick({ type: EntryType.folder, id: folder.id, folder }, event);
|
|
},
|
|
[handleEntryClick],
|
|
);
|
|
|
|
const handleDocumentDragStartLocal = useCallback(
|
|
(event, doc) => {
|
|
suppressDocumentClickRef.current = true;
|
|
onDocumentDragStart?.(event, doc);
|
|
},
|
|
[onDocumentDragStart],
|
|
);
|
|
|
|
const handleDocumentDragEndLocal = useCallback(
|
|
(event) => {
|
|
onDocumentDragEnd?.(event);
|
|
requestAnimationFrame(() => {
|
|
suppressDocumentClickRef.current = false;
|
|
});
|
|
},
|
|
[onDocumentDragEnd],
|
|
);
|
|
|
|
const hasDocumentEntries = useMemo(
|
|
() => entries.some((entry) => entry.type === EntryType.document),
|
|
[entries],
|
|
);
|
|
const showTableRows = entries.length > 0;
|
|
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
|
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
|
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
|
const showSearchHint = showingSearchResults && rows.length > 0;
|
|
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
|
const trailEntries = useMemo(() => {
|
|
if (!breadcrumbEntries.length) {
|
|
return [{ id: 'current-folder', label: currentFolderName }];
|
|
}
|
|
const lastIndex = breadcrumbEntries.length - 1;
|
|
return breadcrumbEntries.map((crumb, index) => ({
|
|
id: crumb.id ?? index,
|
|
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
|
onClick: index < lastIndex && onFolderSelect
|
|
? () => onFolderSelect(crumb.id)
|
|
: null,
|
|
}));
|
|
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
|
|
|
return (
|
|
<section
|
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
|
>
|
|
{showHeader ? (
|
|
<div className="panel-section__header">
|
|
<div className="panel-section__titles">
|
|
<h2 className="documents-panel__title">
|
|
<BreadcrumbTrail
|
|
entries={trailEntries}
|
|
className="documents-panel__breadcrumbs"
|
|
separator="/"
|
|
/>
|
|
</h2>
|
|
{showingSearchResults && (
|
|
<div className="panel-section__subtitle">Search results</div>
|
|
)}
|
|
</div>
|
|
<div className="header-actions">
|
|
<div className="view-toggle" role="group" aria-label="Change view">
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
|
onClick={() => handleSetViewMode('list')}
|
|
aria-pressed={isListView}
|
|
title="List view"
|
|
>
|
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
|
onClick={() => handleSetViewMode('grid')}
|
|
aria-pressed={isGridView}
|
|
title="Icons view"
|
|
>
|
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
|
onClick={() => handleSetViewMode('desk')}
|
|
aria-pressed={isDeskView}
|
|
title="Desk view"
|
|
>
|
|
<IconFileStack className="view-toggle__icon" size={18} />
|
|
</button>
|
|
</div>
|
|
<button className="secondary" onClick={onRefresh}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{showDefaultEmptyState ? (
|
|
<div className="panel-section__body">
|
|
<div className="empty-state">
|
|
Drop files anywhere or onto a folder to upload documents.
|
|
</div>
|
|
</div>
|
|
) : showGridSearchEmptyState ? (
|
|
<div className="panel-section__body">
|
|
<div className="empty-state empty-state--global">
|
|
No documents match the current filters.
|
|
</div>
|
|
</div>
|
|
) : showListSearchEmptyState ? (
|
|
<div className="panel-section__body">
|
|
<div className="empty-state">No documents match the current filters.</div>
|
|
</div>
|
|
) : (
|
|
<div className="panel-section__body">
|
|
<div
|
|
ref={assignScrollRef}
|
|
className="documents-scroll"
|
|
tabIndex={0}
|
|
onFocus={(event) => {
|
|
if (event.target === scrollRef.current) {
|
|
onDocumentListFocus?.();
|
|
}
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.target !== scrollRef.current) {
|
|
return;
|
|
}
|
|
if (onDocumentListKeyDown) {
|
|
onDocumentListKeyDown(event);
|
|
}
|
|
}}
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClearSelection?.();
|
|
}
|
|
}}
|
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
|
>
|
|
{isGridView ? (
|
|
<DocumentsGrid
|
|
entries={entries}
|
|
selectedDocumentIdsSet={selectedSet}
|
|
selectedFolderIdsSet={selectedFolderSet}
|
|
draggingDocumentIdsSet={draggingSet}
|
|
draggedFolderId={draggedFolderId}
|
|
onFolderClick={handleFolderClick}
|
|
onFolderSelect={onFolderSelect}
|
|
onFolderDragOver={onFolderDragOver}
|
|
onFolderDragLeave={onFolderDragLeave}
|
|
onFolderDrop={onFolderDrop}
|
|
onFolderDragStart={onFolderDragStart}
|
|
onFolderDragEnd={onFolderDragEnd}
|
|
onDocumentClick={handleDocumentClick}
|
|
onDocumentOpen={onDocumentOpen}
|
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
|
onDocumentTagDrop={handleDocumentTagDrop}
|
|
ensureAssetUrl={ensureAssetUrl}
|
|
getDocumentAsset={getDocumentAsset}
|
|
gridIconSize={gridIconSize}
|
|
tagLookupById={tagLookupById}
|
|
onTagClick={onTagClick}
|
|
scrollRef={scrollRef}
|
|
onCorrespondentClick={onCorrespondentClick}
|
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
|
onClearSelection={onClearSelection}
|
|
/>
|
|
) : !showTableRows ? null : (
|
|
<DocumentsList
|
|
entries={entries}
|
|
focusedRowKey={focusedRowKey}
|
|
selectedDocumentIdsSet={selectedSet}
|
|
selectedFolderIdsSet={selectedFolderSet}
|
|
draggingDocumentIdsSet={draggingSet}
|
|
draggedFolderId={draggedFolderId}
|
|
ensureAssetUrl={ensureAssetUrl}
|
|
getDocumentAsset={getDocumentAsset}
|
|
getDownloadHref={getDownloadHref}
|
|
onFolderClick={handleFolderClick}
|
|
onFolderSelect={onFolderSelect}
|
|
onFolderDragOver={onFolderDragOver}
|
|
onFolderDragLeave={onFolderDragLeave}
|
|
onFolderDrop={onFolderDrop}
|
|
onFolderDragStart={onFolderDragStart}
|
|
onFolderDragEnd={onFolderDragEnd}
|
|
onFolderRename={onFolderRename}
|
|
onFolderDelete={onFolderDelete}
|
|
onDocumentClick={handleDocumentClick}
|
|
onDocumentOpen={onDocumentOpen}
|
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
|
onDocumentTagDrop={handleDocumentTagDrop}
|
|
onDocumentRename={onDocumentRename}
|
|
onDocumentDelete={onDocumentDelete}
|
|
tagLookupById={tagLookupById}
|
|
onTagClick={onTagClick}
|
|
onCorrespondentClick={onCorrespondentClick}
|
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
|
scrollRef={scrollRef}
|
|
/>
|
|
)}
|
|
</div>
|
|
{showSearchHint && (
|
|
<div className="search-hint">
|
|
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default DocumentsPanel;
|
|
|
|
export const createDocumentsTableHeaderActions = ({
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
}) => {
|
|
const isListView = viewMode === 'list';
|
|
const isGridView = viewMode === 'grid';
|
|
const isDeskView = viewMode === 'desk';
|
|
|
|
return (
|
|
<>
|
|
<div className="view-toggle" role="group" aria-label="Change view">
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
|
onClick={() => onViewModeChange?.('list')}
|
|
aria-pressed={isListView}
|
|
title="List view"
|
|
>
|
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
|
onClick={() => onViewModeChange?.('grid')}
|
|
aria-pressed={isGridView}
|
|
title="Icons view"
|
|
>
|
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
|
onClick={() => onViewModeChange?.('desk')}
|
|
aria-pressed={isDeskView}
|
|
title="Desk view"
|
|
>
|
|
<IconFileStack className="view-toggle__icon" size={18} />
|
|
</button>
|
|
</div>
|
|
<span className="main-content__actions-divider" aria-hidden="true">
|
|
<MinusVerticalIcon />
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={onRefresh}
|
|
aria-label="Refresh"
|
|
title="Refresh"
|
|
>
|
|
<RefreshIcon />
|
|
</button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const createDocumentsSurface = ({
|
|
tableProps,
|
|
parentBreadcrumb,
|
|
onNavigateParent,
|
|
renderSidebarToggle,
|
|
detailProps,
|
|
detailOpen = false,
|
|
}) => {
|
|
const {
|
|
currentFolderName,
|
|
breadcrumbs,
|
|
searchResults,
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
} = tableProps;
|
|
|
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
|
const subtitle = Array.isArray(searchResults)
|
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
|
: null;
|
|
|
|
const actions = createDocumentsTableHeaderActions({
|
|
viewMode,
|
|
onViewModeChange,
|
|
onRefresh,
|
|
});
|
|
|
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
|
|
|
const surfaceConfig = createWorkspaceSurfaceConfig({
|
|
key: 'documents',
|
|
variant: 'documents',
|
|
title,
|
|
subtitle,
|
|
sidebarToggle,
|
|
parentBreadcrumb,
|
|
onNavigateParent,
|
|
actions,
|
|
breadcrumbs,
|
|
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
|
detail,
|
|
});
|
|
|
|
return surfaceConfig;
|
|
};
|