431 lines
15 KiB
React
431 lines
15 KiB
React
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||
import { getTagColorStyle } from '../utils/colors';
|
||
import { DownloadIcon, FolderIcon } from '../ui/icons';
|
||
|
||
const FilterBar = ({
|
||
query,
|
||
onQueryChange,
|
||
tags,
|
||
activeTagIds,
|
||
onToggleTag,
|
||
onClear,
|
||
hasFilters,
|
||
}) => (
|
||
<div className="filter-bar">
|
||
<input
|
||
type="search"
|
||
placeholder="Search documents"
|
||
value={query}
|
||
onChange={(event) => onQueryChange(event.target.value)}
|
||
/>
|
||
<div className="tag-filters">
|
||
{tags.length ? (
|
||
tags.map((tag) => {
|
||
const isActive = activeTagIds.includes(tag.id);
|
||
const style = getTagColorStyle(tag.color);
|
||
const buttonStyle = style
|
||
? {
|
||
...style,
|
||
opacity: isActive ? 1 : 0.95,
|
||
boxShadow: isActive ? '0 0 0 1px var(--shadow-soft)' : undefined,
|
||
}
|
||
: undefined;
|
||
return (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
className={`tag-filter${isActive ? ' active' : ''}`}
|
||
onClick={() => onToggleTag(tag.id)}
|
||
style={buttonStyle}
|
||
>
|
||
{tag.label}
|
||
</button>
|
||
);
|
||
})
|
||
) : (
|
||
<span className="meta">No tags yet</span>
|
||
)}
|
||
</div>
|
||
<div className="filter-actions">
|
||
{hasFilters && (
|
||
<button type="button" className="secondary" onClick={onClear}>
|
||
Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||
const url = useMemo(
|
||
() =>
|
||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||
ensureAssetUrl,
|
||
getAsset: getDocumentAsset,
|
||
}),
|
||
[document, ensureAssetUrl, getDocumentAsset],
|
||
);
|
||
|
||
if (url) {
|
||
return (
|
||
<img
|
||
src={url}
|
||
alt={alt || ''}
|
||
className="document-thumbnail"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return <div className="thumb-placeholder">DOC</div>;
|
||
};
|
||
|
||
const DocumentsTable = ({
|
||
currentFolderName,
|
||
breadcrumbs,
|
||
onRefresh,
|
||
onShowSkeuoWorkspace = () => {},
|
||
onRequestCreateFolder,
|
||
creatingFolder = false,
|
||
subfolders,
|
||
documents,
|
||
searchResults,
|
||
isFilterActive,
|
||
onFolderSelect,
|
||
onFolderDrop,
|
||
onFolderDragOver,
|
||
onFolderDragLeave,
|
||
onFolderDragStart,
|
||
onFolderDragEnd,
|
||
draggedFolderId,
|
||
onFolderDelete,
|
||
onDocumentRowClick,
|
||
onDocumentOpen,
|
||
selectedDocumentIds,
|
||
focusedDocumentId,
|
||
focusedRowKey,
|
||
draggingDocumentIds = [],
|
||
onDocumentDragStart,
|
||
onDocumentDragEnd,
|
||
onDocumentDelete,
|
||
filterBar,
|
||
tagLookupById,
|
||
onDocumentListFocus,
|
||
onDocumentListKeyDown,
|
||
onFocusedRowChange,
|
||
ensureAssetUrl = null,
|
||
getDocumentAsset = () => null,
|
||
getDownloadHref,
|
||
}) => {
|
||
const showingSearchResults = searchResults !== null;
|
||
const rows = showingSearchResults ? searchResults : documents;
|
||
|
||
const selectedSet = useMemo(
|
||
() => new Set(selectedDocumentIds),
|
||
[selectedDocumentIds],
|
||
);
|
||
const draggingSet = useMemo(
|
||
() => new Set(draggingDocumentIds || []),
|
||
[draggingDocumentIds],
|
||
);
|
||
const scrollRef = useRef(null);
|
||
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]);
|
||
|
||
return (
|
||
<section className="documents-panel column">
|
||
<div className="column-header">
|
||
<div className="column-header__titles">
|
||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
||
{breadcrumbs.map((crumb, index) => {
|
||
const isLast = index === breadcrumbs.length - 1;
|
||
return (
|
||
<span key={crumb.id} className="breadcrumb-item">
|
||
{isLast ? (
|
||
<span className="breadcrumb-current">{crumb.name}</span>
|
||
) : (
|
||
<a
|
||
href="#"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
onFolderSelect(crumb.id);
|
||
}}
|
||
>
|
||
{crumb.name}
|
||
</a>
|
||
)}
|
||
{!isLast && <span className="breadcrumb-separator">›</span>}
|
||
</span>
|
||
);
|
||
})}
|
||
</nav>
|
||
{showingSearchResults && (
|
||
<div className="column-subtitle">Search results</div>
|
||
)}
|
||
</div>
|
||
<div className="header-actions">
|
||
<button
|
||
type="button"
|
||
onClick={onRequestCreateFolder}
|
||
disabled={creatingFolder}
|
||
>
|
||
{creatingFolder ? 'Creating…' : 'New folder'}
|
||
</button>
|
||
<button className="secondary" onClick={onRefresh}>
|
||
Refresh
|
||
</button>
|
||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||
Desk View
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="column-body">
|
||
<div className="column-toolbar">{filterBar}</div>
|
||
<div
|
||
ref={scrollRef}
|
||
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);
|
||
}
|
||
}}
|
||
aria-activedescendant={activeDescendantId}
|
||
>
|
||
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
||
<div className="empty-state">
|
||
Drop files anywhere or onto a folder to upload documents.
|
||
</div>
|
||
) : (
|
||
<table aria-multiselectable="true">
|
||
<thead>
|
||
<tr>
|
||
<th className="thumb-column">Preview</th>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Updated</th>
|
||
<th className="actions-column">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{!showingSearchResults &&
|
||
subfolders.map((folder) => {
|
||
const canDragFolder = folder.id !== 'root';
|
||
const isDraggingFolder = draggedFolderId === folder.id;
|
||
return (
|
||
<tr
|
||
key={folder.id}
|
||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
||
}`}
|
||
id={`folder-row-${folder.id}`}
|
||
onClick={() => {
|
||
onFolderSelect(folder.id);
|
||
if (scrollRef.current) {
|
||
scrollRef.current.focus({ preventScroll: true });
|
||
}
|
||
onFocusedRowChange?.(`folder:${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-placeholder icon">
|
||
<FolderIcon className="icon-inline" size={18} />
|
||
</div>
|
||
</td>
|
||
<td>{folder.name}</td>
|
||
<td>Folder</td>
|
||
<td>—</td>
|
||
<td className="actions">
|
||
<button
|
||
type="button"
|
||
className="icon-button danger"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onFolderDelete(folder.id);
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{rows.map((doc) => {
|
||
const isSelected = selectedSet.has(doc.id);
|
||
const isDraggingDoc = draggingSet.has(doc.id);
|
||
const rowClasses = ['document'];
|
||
if (isSelected) rowClasses.push('selected');
|
||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||
const downloadHref = getDownloadHref?.(doc) || null;
|
||
|
||
return (
|
||
<tr
|
||
key={doc.id}
|
||
className={rowClasses.join(' ')}
|
||
id={`document-row-${doc.id}`}
|
||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||
draggable
|
||
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||
onDragEnd={onDocumentDragEnd}
|
||
>
|
||
<td className="thumb-cell">
|
||
<DocumentThumbnailImage
|
||
document={doc}
|
||
ensureAssetUrl={ensureAssetUrl}
|
||
getDocumentAsset={getDocumentAsset}
|
||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="doc-name">
|
||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||
{(doc.tags || []).length > 0 && (
|
||
<div className="doc-name__tags">
|
||
{(doc.tags || []).map((tag) => {
|
||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||
const style = getTagColorStyle(colorSource);
|
||
return (
|
||
<span
|
||
key={tag.id}
|
||
className="badge tag-chip"
|
||
style={style || undefined}
|
||
title={tag.label}
|
||
>
|
||
{tag.label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td>{doc.content_type || 'Document'}</td>
|
||
<td>
|
||
{doc.updated_at
|
||
? new Date(doc.updated_at).toLocaleString()
|
||
: '—'}
|
||
</td>
|
||
<td className="actions">
|
||
<div className="action-buttons">
|
||
{downloadHref ? (
|
||
<a
|
||
className="button-link with-icon"
|
||
href={downloadHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onAuxClick={(event) => event.stopPropagation()}
|
||
onContextMenu={(event) => event.stopPropagation()}
|
||
>
|
||
<DownloadIcon className="icon-inline" />
|
||
<span>Download</span>
|
||
</a>
|
||
) : (
|
||
<span className="meta">No download</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDocumentDelete?.(doc.id);
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
{rows.length === 0 && isFilterActive && (
|
||
<div className="empty-state">No documents match the current filters.</div>
|
||
)}
|
||
{showingSearchResults && rows.length > 0 && (
|
||
<div className="search-hint">
|
||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
export default DocumentsTable;
|
||
export { FilterBar, DocumentThumbnailImage };
|