split
This commit is contained in:
@@ -0,0 +1,424 @@
|
|||||||
|
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)}
|
||||||
|
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 };
|
||||||
+11
-686
@@ -16,7 +16,6 @@ import {
|
|||||||
Routes,
|
Routes,
|
||||||
Outlet,
|
Outlet,
|
||||||
useLocation,
|
useLocation,
|
||||||
useMatch,
|
|
||||||
useNavigate,
|
useNavigate,
|
||||||
matchPath,
|
matchPath,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
@@ -24,14 +23,10 @@ import './styles.css';
|
|||||||
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
|
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
|
||||||
import useApiError from './hooks/useApiError';
|
import useApiError from './hooks/useApiError';
|
||||||
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
||||||
import {
|
import { DownloadIcon, EditIcon } from './ui/icons';
|
||||||
IconChevronRight,
|
import { getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors';
|
||||||
IconDownload as TablerDownload,
|
import Sidebar from './sidebar/Sidebar';
|
||||||
IconFolderFilled,
|
import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
|
||||||
IconPencil,
|
|
||||||
IconTagFilled,
|
|
||||||
IconTrash,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
|
|
||||||
const runtimeApiBase =
|
const runtimeApiBase =
|
||||||
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
|
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
|
||||||
@@ -145,44 +140,6 @@ const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
|
|||||||
const hasFiles = (event) =>
|
const hasFiles = (event) =>
|
||||||
Array.from(event.dataTransfer?.types || []).includes('Files');
|
Array.from(event.dataTransfer?.types || []).includes('Files');
|
||||||
|
|
||||||
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
|
||||||
|
|
||||||
const hexToRgb = (input) => {
|
|
||||||
if (!input) return null;
|
|
||||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
|
||||||
if (!match) return null;
|
|
||||||
const value = parseInt(match[1], 16);
|
|
||||||
return {
|
|
||||||
r: (value >> 16) & 0xff,
|
|
||||||
g: (value >> 8) & 0xff,
|
|
||||||
b: value & 0xff,
|
|
||||||
hex: `#${match[1].toLowerCase()}`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const relativeLuminance = ({ r, g, b }) => {
|
|
||||||
const transform = (channel) => {
|
|
||||||
const normalized = channel / 255;
|
|
||||||
return normalized <= 0.03928
|
|
||||||
? normalized / 12.92
|
|
||||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
||||||
};
|
|
||||||
const [red, green, blue] = [transform(r), transform(g), transform(b)];
|
|
||||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTagColorStyle = (hex) => {
|
|
||||||
const rgb = hexToRgb(hex);
|
|
||||||
if (!rgb) return null;
|
|
||||||
const luminance = relativeLuminance(rgb);
|
|
||||||
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
|
|
||||||
return {
|
|
||||||
backgroundColor: rgb.hex,
|
|
||||||
borderColor: rgb.hex,
|
|
||||||
color: textColor,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRootNode = () => ({
|
const createRootNode = () => ({
|
||||||
id: 'root',
|
id: 'root',
|
||||||
name: DEFAULT_FOLDER_NAME,
|
name: DEFAULT_FOLDER_NAME,
|
||||||
@@ -235,544 +192,7 @@ const LoginView = ({ onSubmit, status }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FolderNode = ({
|
|
||||||
node,
|
|
||||||
depth,
|
|
||||||
isSelected,
|
|
||||||
onToggle,
|
|
||||||
onSelect,
|
|
||||||
onDrop,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDelete,
|
|
||||||
renderChildren,
|
|
||||||
onFolderDragStart,
|
|
||||||
onFolderDragEnd,
|
|
||||||
draggingFolderId,
|
|
||||||
}) => {
|
|
||||||
const isRoot = node.id === 'root';
|
|
||||||
const hasChildren = node.children.length > 0;
|
|
||||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
|
||||||
const showChevron = !isRoot && hasChildren;
|
|
||||||
const icon = showChevron ? (
|
|
||||||
<IconChevronRight className="icon toggle-icon" size="1em" stroke={1.6} />
|
|
||||||
) : null;
|
|
||||||
const canDrag = !isRoot;
|
|
||||||
const isDragging = draggingFolderId === node.id;
|
|
||||||
const isExpanded = isRoot ? true : Boolean(node.expanded);
|
|
||||||
const rowClasses = ['folder-row'];
|
|
||||||
if (isSelected) {
|
|
||||||
rowClasses.push('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleClick = (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (canToggle) {
|
|
||||||
onToggle(node.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
|
|
||||||
<div
|
|
||||||
className={rowClasses.join(' ')}
|
|
||||||
draggable={canDrag}
|
|
||||||
onClick={() => onSelect(node.id)}
|
|
||||||
onDragOver={(event) => onDragOver(event, node.id)}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(event) => onDrop(event, node.id)}
|
|
||||||
onDragStart={(event) => {
|
|
||||||
if (!canDrag || !onFolderDragStart) return;
|
|
||||||
onFolderDragStart(event, node.id);
|
|
||||||
}}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
if (onFolderDragEnd) {
|
|
||||||
onFolderDragEnd(event);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isRoot && (
|
|
||||||
<span
|
|
||||||
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
|
||||||
onClick={handleToggleClick}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="name">
|
|
||||||
<IconFolderFilled
|
|
||||||
className="icon icon--fill folder-icon"
|
|
||||||
size="1em"
|
|
||||||
stroke={0}
|
|
||||||
/>
|
|
||||||
{node.name}
|
|
||||||
</span>
|
|
||||||
{node.id !== 'root' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDelete(node.id);
|
|
||||||
}}
|
|
||||||
title="Delete folder"
|
|
||||||
aria-label={`Delete folder ${node.name}`}
|
|
||||||
>
|
|
||||||
<IconTrash className="icon icon-trash" size="1em" stroke={1.6} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isExpanded && node.children.length > 0 && (
|
|
||||||
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
|
|
||||||
{renderChildren(node.children, depth + 1)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 rgba(0, 0, 0, 0.18)' : 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,
|
|
||||||
}) => {
|
|
||||||
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={() => {
|
|
||||||
scrollRef.current?.focus({ preventScroll: true });
|
|
||||||
onFocusedRowChange?.(`folder:${folder.id}`);
|
|
||||||
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-placeholder icon">
|
|
||||||
<IconFolderFilled
|
|
||||||
className="icon icon--fill icon-inline"
|
|
||||||
size="1.1em"
|
|
||||||
stroke={0}
|
|
||||||
/>
|
|
||||||
</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 rowClasses = ['document'];
|
|
||||||
if (isSelected) rowClasses.push('selected');
|
|
||||||
if (
|
|
||||||
focusedDocumentId === doc.id || focusedRowKey === `document:${doc.id}`
|
|
||||||
) {
|
|
||||||
rowClasses.push('focused');
|
|
||||||
}
|
|
||||||
if (draggingSet.has(doc.id)) {
|
|
||||||
rowClasses.push('dragging');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={doc.id}
|
|
||||||
className={rowClasses.join(' ')}
|
|
||||||
id={`document-row-${doc.id}`}
|
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={(event) => {
|
|
||||||
scrollRef.current?.focus({ preventScroll: true });
|
|
||||||
onFocusedRowChange?.(`document:${doc.id}`);
|
|
||||||
onDocumentRowClick(doc.id, event);
|
|
||||||
}}
|
|
||||||
onDoubleClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (onDocumentOpen) {
|
|
||||||
onDocumentOpen(doc.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
draggable
|
|
||||||
onDragStart={(event) => onDocumentDragStart(event, doc.id)}
|
|
||||||
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">
|
|
||||||
{doc.current_version?.download_path ? (
|
|
||||||
<a
|
|
||||||
className="button-link with-icon"
|
|
||||||
href={resolveApiPath(doc.current_version.download_path)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onAuxClick={(event) => event.stopPropagation()}
|
|
||||||
onContextMenu={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<TablerDownload
|
|
||||||
className="icon icon-inline"
|
|
||||||
size="1em"
|
|
||||||
stroke={1.6}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeStackAngle = (docId, index) => {
|
const computeStackAngle = (docId, index) => {
|
||||||
if (index === 0) return 0;
|
if (index === 0) return 0;
|
||||||
@@ -1122,7 +542,7 @@ const DetailPanel = ({
|
|||||||
aria-label="Edit title"
|
aria-label="Edit title"
|
||||||
title="Edit title"
|
title="Edit title"
|
||||||
>
|
>
|
||||||
<IconPencil className="icon icon-inline" size="1em" stroke={1.6} />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1166,7 +586,7 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TablerDownload className="icon icon-inline" size="1em" stroke={1.6} />
|
<DownloadIcon className="icon-inline" />
|
||||||
<span>Download</span>
|
<span>Download</span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@@ -1407,7 +827,7 @@ const PreviewWorkspace = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TablerDownload className="icon icon-inline" size="1em" stroke={1.6} />
|
<DownloadIcon className="icon-inline" />
|
||||||
<span>Download</span>
|
<span>Download</span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@@ -1440,105 +860,6 @@ const PreviewWorkspace = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Sidebar = ({
|
|
||||||
folderNodes,
|
|
||||||
onToggle,
|
|
||||||
onSelect,
|
|
||||||
onDrop,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDeleteFolder,
|
|
||||||
selectedFolder,
|
|
||||||
onFolderDragStart,
|
|
||||||
onFolderDragEnd,
|
|
||||||
draggedFolderId,
|
|
||||||
onShowTags,
|
|
||||||
tags = [],
|
|
||||||
}) => {
|
|
||||||
const handleShowTags = onShowTags || (() => {});
|
|
||||||
const tagsRouteMatch = useMatch('/tags');
|
|
||||||
const isTagsRoute = Boolean(tagsRouteMatch);
|
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
|
||||||
(ids, depth) =>
|
|
||||||
ids.map((id) => {
|
|
||||||
const node = folderNodes.get(id);
|
|
||||||
if (!node) return null;
|
|
||||||
return (
|
|
||||||
<FolderNode
|
|
||||||
key={id}
|
|
||||||
node={node}
|
|
||||||
depth={depth}
|
|
||||||
isSelected={selectedFolder === id}
|
|
||||||
onToggle={() => onToggle(id)}
|
|
||||||
onSelect={onSelect}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDelete={onDeleteFolder}
|
|
||||||
renderChildren={renderNodes}
|
|
||||||
onFolderDragStart={onFolderDragStart}
|
|
||||||
onFolderDragEnd={onFolderDragEnd}
|
|
||||||
draggingFolderId={draggedFolderId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
folderNodes,
|
|
||||||
selectedFolder,
|
|
||||||
onToggle,
|
|
||||||
onSelect,
|
|
||||||
onDrop,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDeleteFolder,
|
|
||||||
onFolderDragStart,
|
|
||||||
onFolderDragEnd,
|
|
||||||
draggedFolderId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const rootNode = folderNodes.get('root');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="sidebar column">
|
|
||||||
<div className="sidebar-section sidebar-section--folders">
|
|
||||||
<div className="sidebar-section__header">
|
|
||||||
<h3>Folders</h3>
|
|
||||||
</div>
|
|
||||||
<ul className="folder-tree">
|
|
||||||
{rootNode && renderNodes([rootNode.id], 0)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-section">
|
|
||||||
<div className="sidebar-section__header">
|
|
||||||
<h3>Tags</h3>
|
|
||||||
<span className="meta">{tags.length}</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className={`sidebar-item${isTagsRoute ? ' active' : ''}`}
|
|
||||||
onClick={handleShowTags}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleShowTags();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconTagFilled
|
|
||||||
className="icon icon--fill sidebar-item__icon"
|
|
||||||
size="1em"
|
|
||||||
stroke={0}
|
|
||||||
/>
|
|
||||||
<span>All tags</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [draftLabel, setDraftLabel] = useState('');
|
const [draftLabel, setDraftLabel] = useState('');
|
||||||
@@ -5087,6 +4408,10 @@ const AppLayout = () => {
|
|||||||
onFocusedRowChange: setFocusedRowKey,
|
onFocusedRowChange: setFocusedRowKey,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
getDownloadHref: (doc) =>
|
||||||
|
doc?.current_version?.download_path
|
||||||
|
? resolveApiPath(doc.current_version.download_path)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailPanelProps = {
|
const detailPanelProps = {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useMatch } from 'react-router-dom';
|
||||||
|
import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons';
|
||||||
|
|
||||||
|
const FolderNode = ({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
isSelected,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
onDrop,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDelete,
|
||||||
|
renderChildren,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggingFolderId,
|
||||||
|
}) => {
|
||||||
|
const isRoot = node.id === 'root';
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
||||||
|
const showChevron = !isRoot && hasChildren;
|
||||||
|
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||||
|
const canDrag = !isRoot;
|
||||||
|
const isDragging = draggingFolderId === node.id;
|
||||||
|
const isExpanded = isRoot ? true : Boolean(node.expanded);
|
||||||
|
const rowClasses = ['folder-row'];
|
||||||
|
if (isSelected) {
|
||||||
|
rowClasses.push('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleClick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (canToggle) {
|
||||||
|
onToggle(node.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`folder-node${isDragging ? ' is-dragging' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={rowClasses.join(' ')}
|
||||||
|
draggable={canDrag}
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
onDragOver={(event) => onDragOver(event, node.id)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(event) => onDrop(event, node.id)}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (!canDrag || !onFolderDragStart) return;
|
||||||
|
onFolderDragStart(event, node.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
if (onFolderDragEnd) {
|
||||||
|
onFolderDragEnd(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
||||||
|
onClick={handleToggleClick}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="name">
|
||||||
|
<FolderIcon className="folder-icon" />
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
{node.id !== 'root' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete(node.id);
|
||||||
|
}}
|
||||||
|
title="Delete folder"
|
||||||
|
aria-label={`Delete folder ${node.name}`}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-trash" size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && node.children.length > 0 && (
|
||||||
|
<ul className={`folder-children${depth === 0 ? ' folder-children--level1' : ''}`}>
|
||||||
|
{renderChildren(node.children, depth + 1)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar = ({
|
||||||
|
folderNodes,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
onDrop,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDeleteFolder,
|
||||||
|
selectedFolder,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onShowTags,
|
||||||
|
tags = [],
|
||||||
|
}) => {
|
||||||
|
const handleShowTags = onShowTags || (() => {});
|
||||||
|
const tagsRouteMatch = useMatch('/tags');
|
||||||
|
const isTagsRoute = Boolean(tagsRouteMatch);
|
||||||
|
|
||||||
|
const renderNodes = useCallback(
|
||||||
|
(ids, depth) =>
|
||||||
|
ids.map((id) => {
|
||||||
|
const node = folderNodes.get(id);
|
||||||
|
if (!node) return null;
|
||||||
|
return (
|
||||||
|
<FolderNode
|
||||||
|
key={id}
|
||||||
|
node={node}
|
||||||
|
depth={depth}
|
||||||
|
isSelected={selectedFolder === id}
|
||||||
|
onToggle={() => onToggle(id)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDelete={onDeleteFolder}
|
||||||
|
renderChildren={renderNodes}
|
||||||
|
onFolderDragStart={onFolderDragStart}
|
||||||
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
|
draggingFolderId={draggedFolderId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
folderNodes,
|
||||||
|
selectedFolder,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
onDrop,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDeleteFolder,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootNode = folderNodes.get('root');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar column">
|
||||||
|
<div className="sidebar-section sidebar-section--folders">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Folders</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="folder-tree">
|
||||||
|
{rootNode && renderNodes([rootNode.id], 0)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<span className="meta">{tags.length}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`sidebar-item${isTagsRoute ? ' active' : ''}`}
|
||||||
|
onClick={handleShowTags}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleShowTags();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TagIcon className="sidebar-item__icon" />
|
||||||
|
<span>All tags</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
|
export { FolderNode };
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
IconChevronRight as TablerChevronRight,
|
||||||
|
IconDownload as TablerDownload,
|
||||||
|
IconFolderFilled,
|
||||||
|
IconPencil,
|
||||||
|
IconTagFilled,
|
||||||
|
IconTrash,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||||
|
|
||||||
|
export const ChevronIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<TablerChevronRight
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconTrash
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconPencil
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||||
|
<IconFolderFilled
|
||||||
|
className={composeClassName('icon icon--fill', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||||
|
<IconTagFilled
|
||||||
|
className={composeClassName('icon icon--fill', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<TablerDownload
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
ChevronIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EditIcon,
|
||||||
|
FolderIcon,
|
||||||
|
TagIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
export const hexToRgb = (input) => {
|
||||||
|
if (!input) return null;
|
||||||
|
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const value = parseInt(match[1], 16);
|
||||||
|
return {
|
||||||
|
r: (value >> 16) & 0xff,
|
||||||
|
g: (value >> 8) & 0xff,
|
||||||
|
b: value & 0xff,
|
||||||
|
hex: `#${match[1].toLowerCase()}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeLuminance = ({ r, g, b }) => {
|
||||||
|
const transform = (channel) => {
|
||||||
|
const normalized = channel / 255;
|
||||||
|
return normalized <= 0.03928
|
||||||
|
? normalized / 12.92
|
||||||
|
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [red, green, blue] = [transform(r), transform(g), transform(b)];
|
||||||
|
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTagColorStyle = (hex) => {
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
if (!rgb) return null;
|
||||||
|
const luminance = relativeLuminance(rgb);
|
||||||
|
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
|
||||||
|
return {
|
||||||
|
backgroundColor: rgb.hex,
|
||||||
|
borderColor: rgb.hex,
|
||||||
|
color: textColor,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { HEX_COLOR_PATTERN };
|
||||||
Reference in New Issue
Block a user