diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx
new file mode 100644
index 0000000..0965ee4
--- /dev/null
+++ b/frontend/src/documents/DocumentsTable.jsx
@@ -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,
+}) => (
+
+
onQueryChange(event.target.value)}
+ />
+
+ {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 (
+
+ );
+ })
+ ) : (
+ No tags yet
+ )}
+
+
+ {hasFilters && (
+
+ )}
+
+
+);
+
+const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
+ const url = useMemo(
+ () =>
+ resolveDocumentAssetUrl(document, 'thumbnail', {
+ ensureAssetUrl,
+ getAsset: getDocumentAsset,
+ }),
+ [document, ensureAssetUrl, getDocumentAsset],
+ );
+
+ if (url) {
+ return (
+
+ );
+ }
+
+ return DOC
;
+};
+
+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 (
+
+
+
+
+
+
+
+
+
+
+
{filterBar}
+
{
+ 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 ? (
+
+ Drop files anywhere or onto a folder to upload documents.
+
+ ) : (
+
+
+
+ | Preview |
+ Name |
+ Type |
+ Updated |
+ Actions |
+
+
+
+ {!showingSearchResults &&
+ subfolders.map((folder) => {
+ const canDragFolder = folder.id !== 'root';
+ const isDraggingFolder = draggedFolderId === folder.id;
+ return (
+ 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);
+ }
+ }}
+ >
+ |
+
+
+
+ |
+ {folder.name} |
+ Folder |
+ — |
+
+
+ |
+
+ );
+ })}
+ {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 (
+ onDocumentRowClick(doc.id, event)}
+ onDoubleClick={() => onDocumentOpen(doc.id)}
+ draggable
+ onDragStart={(event) => onDocumentDragStart(event, doc)}
+ onDragEnd={onDocumentDragEnd}
+ >
+ |
+
+ |
+
+
+ {doc.title || doc.original_name}
+ {(doc.tags || []).length > 0 && (
+
+ {(doc.tags || []).map((tag) => {
+ const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
+ const style = getTagColorStyle(colorSource);
+ return (
+
+ {tag.label}
+
+ );
+ })}
+
+ )}
+
+ |
+ {doc.content_type || 'Document'} |
+
+ {doc.updated_at
+ ? new Date(doc.updated_at).toLocaleString()
+ : '—'}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+ )}
+
+ {rows.length === 0 && isFilterActive && (
+
No documents match the current filters.
+ )}
+ {showingSearchResults && rows.length > 0 && (
+
+ Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
+
+ )}
+
+
+ );
+};
+
+export default DocumentsTable;
+export { FilterBar, DocumentThumbnailImage };
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index 65ea337..63a81c6 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -16,7 +16,6 @@ import {
Routes,
Outlet,
useLocation,
- useMatch,
useNavigate,
matchPath,
} from 'react-router-dom';
@@ -24,14 +23,10 @@ import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws';
-import {
- IconChevronRight,
- IconDownload as TablerDownload,
- IconFolderFilled,
- IconPencil,
- IconTagFilled,
- IconTrash,
-} from '@tabler/icons-react';
+import { DownloadIcon, EditIcon } from './ui/icons';
+import { getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors';
+import Sidebar from './sidebar/Sidebar';
+import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
const runtimeApiBase =
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
@@ -145,44 +140,6 @@ const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
const hasFiles = (event) =>
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 = () => ({
id: 'root',
name: DEFAULT_FOLDER_NAME,
@@ -235,544 +192,7 @@ const LoginView = ({ onSubmit, status }) => (
);
-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 ? (
-
- ) : 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 (
-
- 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 && (
-
- {icon}
-
- )}
-
-
- {node.name}
-
- {node.id !== 'root' && (
-
- )}
-
- {isExpanded && node.children.length > 0 && (
-
- {renderChildren(node.children, depth + 1)}
-
- )}
-
- );
-};
-
-const FilterBar = ({
- query,
- onQueryChange,
- tags,
- activeTagIds,
- onToggleTag,
- onClear,
- hasFilters,
-}) => (
-
-
onQueryChange(event.target.value)}
- />
-
- {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 (
-
- );
- })
- ) : (
- No tags yet
- )}
-
-
- {hasFilters && (
-
- )}
-
-
-);
-
-const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
- const url = useMemo(
- () =>
- resolveDocumentAssetUrl(document, 'thumbnail', {
- ensureAssetUrl,
- getAsset: getDocumentAsset,
- }),
- [document, ensureAssetUrl, getDocumentAsset],
- );
-
- if (url) {
- return (
-
- );
- }
-
- return DOC
;
-};
-
-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 (
-
-
-
-
-
-
-
-
-
-
-
{filterBar}
-
{
- 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 ? (
-
- Drop files anywhere or onto a folder to upload documents.
-
- ) : (
-
-
-
- | Preview |
- Name |
- Type |
- Updated |
- Actions |
-
-
-
- {!showingSearchResults &&
- subfolders.map((folder) => {
- const canDragFolder = folder.id !== 'root';
- const isDraggingFolder = draggedFolderId === folder.id;
- return (
- {
- 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);
- }
- }}
- >
- |
-
-
-
- |
- {folder.name} |
- Folder |
- — |
-
-
- |
-
- );
- })}
- {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 (
- {
- 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}
- >
- |
-
- |
-
-
- {doc.title || doc.original_name}
- {(doc.tags || []).length > 0 && (
-
- {(doc.tags || []).map((tag) => {
- const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
- const style = getTagColorStyle(colorSource);
- return (
-
- {tag.label}
-
- );
- })}
-
- )}
-
- |
- {doc.content_type || 'Document'} |
- {
- doc.updated_at
- ? new Date(doc.updated_at).toLocaleString()
- : '—'
- } |
-
-
- |
-
- );
- })}
-
-
- )}
-
- {rows.length === 0 && isFilterActive && (
-
No documents match the current filters.
- )}
- {showingSearchResults && rows.length > 0 && (
-
- Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
-
- )}
-
-
- );
-};
const computeStackAngle = (docId, index) => {
if (index === 0) return 0;
@@ -1122,7 +542,7 @@ const DetailPanel = ({
aria-label="Edit title"
title="Edit title"
>
-
+
>
)}
@@ -1166,7 +586,7 @@ const DetailPanel = ({
}
}}
>
-
+
Download