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 ( + {alt + ); + } + + 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 ( +
+
+
+ + {showingSearchResults && ( +
Search results
+ )} +
+
+ + + +
+
+
+
{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. +
+ ) : ( + + + + + + + + + + + + {!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); + } + }} + > + + + + + + + ); + })} + {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} + > + + + + + + + ); + })} + +
PreviewNameTypeUpdatedActions
+
+ +
+
{folder.name}Folder + +
+ + +
+ {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() + : '—'} + +
+ {downloadHref ? ( + event.stopPropagation()} + onAuxClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + > + + Download + + ) : ( + No download + )} + +
+
+ )} +
+ {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 && ( - - )} -
  • - ); -}; - -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 ( - {alt - ); - } - - 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 ( -
    -
    -
    - - {showingSearchResults && ( -
    Search results
    - )} -
    -
    - - - -
    -
    -
    -
    {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. -
    - ) : ( - - - - - - - - - - - - {!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); - } - }} - > - - - - - - - ); - })} - {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} - > - - - - - - - ); - })} - -
    PreviewNameTypeUpdatedActions
    -
    - -
    -
    {folder.name}Folder - -
    - - -
    - {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() - : '—' - } -
    - {doc.current_version?.download_path ? ( - event.stopPropagation()} - onAuxClick={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - > - - Download - - ) : ( - No download - )} - -
    -
    - )} -
    - {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 + )} + + {isExpanded && node.children.length > 0 && ( +
      + {renderChildren(node.children, depth + 1)} +
    + )} + + ); +}; + +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 ( + 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 ( + + ); +}; + +export default Sidebar; +export { FolderNode }; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js new file mode 100644 index 0000000..d6c7044 --- /dev/null +++ b/frontend/src/ui/icons.js @@ -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 }) => ( + +); + +export const TrashIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( + +); + +export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( + +); + +export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export default { + ChevronIcon, + TrashIcon, + EditIcon, + FolderIcon, + TagIcon, + DownloadIcon, +}; diff --git a/frontend/src/utils/colors.js b/frontend/src/utils/colors.js new file mode 100644 index 0000000..cc58593 --- /dev/null +++ b/frontend/src/utils/colors.js @@ -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 };