From bfab2f2cdcc1f141389ded8a32b32bb49a7696bf Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 9 Nov 2025 19:50:16 +0100 Subject: [PATCH 01/14] refactor --- frontend/src/app/useWorkspaceSurface.js | 2 +- frontend/src/desktop/DesktopWorkspace.jsx | 194 +-- frontend/src/desktop/createDesktopSurface.js | 113 ++ .../src/desktop/hooks/usePreviewMetadata.js | 86 ++ frontend/src/documents/DocumentsPanel.jsx | 1059 +---------------- .../src/documents/panel/DocumentsPanel.jsx | 725 +++++++++++ .../src/documents/panel/DocumentsToolbar.jsx | 158 +++ .../documents/panel/SortFieldQuickMenu.jsx | 64 + .../documents/panel/createDocumentsSurface.js | 119 ++ 9 files changed, 1271 insertions(+), 1249 deletions(-) create mode 100644 frontend/src/desktop/createDesktopSurface.js create mode 100644 frontend/src/desktop/hooks/usePreviewMetadata.js create mode 100644 frontend/src/documents/panel/DocumentsPanel.jsx create mode 100644 frontend/src/documents/panel/DocumentsToolbar.jsx create mode 100644 frontend/src/documents/panel/SortFieldQuickMenu.jsx create mode 100644 frontend/src/documents/panel/createDocumentsSurface.js diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index c3ec2db..81fcb80 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { SidebarExpandIcon } from '../ui/icons'; import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; -import { createDesktopSurface } from '../desktop/DesktopWorkspace'; +import createDesktopSurface from '../desktop/createDesktopSurface'; export const useWorkspaceSurface = ({ sidebarCollapsed, diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.jsx index ad65cd2..37c794d 100644 --- a/frontend/src/desktop/DesktopWorkspace.jsx +++ b/frontend/src/desktop/DesktopWorkspace.jsx @@ -8,12 +8,8 @@ import React, { useSyncExternalStore, } from 'react'; import { createPortal } from 'react-dom'; -import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; +import { resolveDocumentAssetUrl } from '../asset_manager'; import { CloseIcon } from '../ui/icons'; -import SelectionFloatingActions from '../documents/SelectionFloatingActions'; -import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; -import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; import { formatTransform } from './math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; @@ -29,91 +25,11 @@ import { import useDeskPointer from './pointer/useDeskPointer'; import useDeskTagInteractions from './tags/useDeskTagInteractions'; import DesktopDocumentCard from './DesktopDocumentCard'; +import usePreviewMetadata from './hooks/usePreviewMetadata'; import './DesktopWorkspace.css'; const DEBUG_DRAG = false; const DEBUG_FOCUS = false; - -const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { - const [metadataMap, setMetadataMap] = useState(() => new Map()); - - useEffect(() => { - let cancelled = false; - const docs = Array.isArray(documents) ? documents : []; - if (!docs.length) { - setMetadataMap(new Map()); - return () => { - cancelled = true; - }; - } - - const fetchMetadataForDoc = async (doc) => { - if (!doc?.id) { - return null; - } - - const docId = String(doc.id); - const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); - - let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); - - const hasDimensions = (meta) => - Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && - Number(meta.width) > 0 && Number(meta.height) > 0; - - if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { - try { - const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); - if (ensured) { - asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); - } - } catch (error) { - console.warn('[desk] ensureDocumentSize metadata fetch failed', error); - } - } - - if (!hasDimensions(metadata)) { - return null; - } - - const width = Number(metadata.width); - const height = Number(metadata.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return [docId, { width, height }]; - }; - - Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) - .then((entries) => { - if (cancelled) return; - const next = new Map(); - entries.forEach((entry) => { - if (entry) { - next.set(entry[0], entry[1]); - } - }); - setMetadataMap(next); - }) - .catch(() => { - if (!cancelled) { - setMetadataMap(new Map()); - } - }); - - return () => { - cancelled = true; - }; - }, [documents, getDocumentAsset, ensureAssetUrl]); - - return metadataMap; -}; - const DesktopWorkspace = ({ documents = [], searchResults = null, @@ -1082,109 +998,3 @@ const DesktopHelpOverlay = ({ open = false, onClose = null }) => { portalTarget, ); }; - -export const createDesktopSurface = ({ - workspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailProps = null, - detailOpen = false, -}) => { - if (!workspaceProps) { - return null; - } - - const { - currentFolderName, - searchResults, - onRefresh, - viewMode, - onViewModeChange, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - } = workspaceProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - const actions = createDocumentsTableHeaderActions({ - viewMode: viewMode || 'desk', - onViewModeChange, - onRefresh, - onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'workspace', - variant: 'workspace', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs: workspaceProps?.breadcrumbs || null, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return { - ...surfaceConfig, - supportsDetail: Boolean(detailProps), - }; -}; diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.js new file mode 100644 index 0000000..50a860a --- /dev/null +++ b/frontend/src/desktop/createDesktopSurface.js @@ -0,0 +1,113 @@ +import React from 'react'; +import SelectionFloatingActions from '../documents/SelectionFloatingActions'; +import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; +import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; +import DetailPanel from '../detail/DetailPanel'; +import DesktopWorkspace from './DesktopWorkspace'; + +const createDesktopSurface = ({ + workspaceProps, + renderSidebarToggle, + parentBreadcrumb, + onNavigateParent, + detailProps = null, + detailOpen = false, +}) => { + if (!workspaceProps) { + return null; + } + + const { + currentFolderName, + searchResults, + onRefresh, + viewMode, + onViewModeChange, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + } = workspaceProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + const actions = createDocumentsTableHeaderActions({ + viewMode: viewMode || 'desk', + onViewModeChange, + onRefresh, + onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + const surfaceConfig = createWorkspaceSurfaceConfig({ + key: 'workspace', + variant: 'workspace', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs: workspaceProps?.breadcrumbs || null, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); + + return { + ...surfaceConfig, + supportsDetail: Boolean(detailProps), + }; +}; + +export default createDesktopSurface; diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.js b/frontend/src/desktop/hooks/usePreviewMetadata.js new file mode 100644 index 0000000..6078c21 --- /dev/null +++ b/frontend/src/desktop/hooks/usePreviewMetadata.js @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { createAssetView } from '../../asset_manager'; + +const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { + const [metadataMap, setMetadataMap] = useState(() => new Map()); + + useEffect(() => { + let cancelled = false; + const docs = Array.isArray(documents) ? documents : []; + if (!docs.length) { + setMetadataMap(new Map()); + return () => { + cancelled = true; + }; + } + + const fetchMetadataForDoc = async (doc) => { + if (!doc?.id) { + return null; + } + + const docId = String(doc.id); + const resolveAsset = (type) => + (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); + + let asset = resolveAsset('preview') || resolveAsset('thumbnail'); + let view = createAssetView(asset); + let metadata = view.getPrimaryMetadata(); + + const hasDimensions = (meta) => + Number.isFinite(Number(meta?.width)) && + Number.isFinite(Number(meta?.height)) && + Number(meta.width) > 0 && + Number(meta.height) > 0; + + if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { + try { + const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); + if (ensured) { + asset = ensured; + view = createAssetView(asset); + metadata = view.getPrimaryMetadata(); + } + } catch (error) { + console.warn('[desk] ensureDocumentSize metadata fetch failed', error); + } + } + + if (!hasDimensions(metadata)) { + return null; + } + + return { + docId, + width: Number(metadata.width), + height: Number(metadata.height), + }; + }; + + let mounted = true; + (async () => { + const entries = await Promise.all(docs.map(fetchMetadataForDoc)); + if (!mounted || cancelled) { + return; + } + const next = new Map(); + entries.forEach((entry) => { + if (entry && entry.docId) { + next.set(entry.docId, entry); + } + }); + if (!cancelled) { + setMetadataMap(next); + } + })(); + + return () => { + cancelled = true; + mounted = false; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + +export default usePreviewMetadata; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index 740b423..d076f21 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -1,1056 +1,3 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ViewListIcon, - ViewGridIcon, - IconFileStack, - RefreshIcon, - MinusVerticalIcon, - InfoIcon, - FoldersIcon, - FoldersOffIcon, - SortAscendingLettersIcon, - SortDescendingLettersIcon, -} from '../ui/icons'; -import QuickAddMenu from '../ui/QuickAddMenu'; -import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -import createWorkspaceSurfaceConfig from './workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; -import DocumentsGrid from './DocumentsGrid'; -import DocumentsList from './DocumentsList'; -import { isTagTransferEvent } from './tagTransfer'; -import SelectionFloatingActions from './SelectionFloatingActions'; -import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; -import { useAssetNavigator } from '../hooks/useAssetNavigator'; -import { isPointerModifierEvent, isPrimaryPointerEvent } from './useEntryPointer'; - -const DEFAULT_GRID_ICON_SIZE = 144; - -const EntryType = { - folder: 'folder', - document: 'document', -}; - -const SORT_OPTIONS = [ - { value: 'title', label: 'Title' }, - { value: 'issued_at', label: 'Issued date' }, - { value: 'created_at', label: 'Added' }, - { value: 'updated_at', label: 'Updated date' }, -]; - -const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => { - const next = accumulator; - next[option.value] = option.label; - return next; -}, {}); - -const DocumentsPanel = ({ - currentFolderName, - breadcrumbs, - onRefresh, - subfolders, - documents, - searchResults, - isFilterActive = false, - onFolderSelect, - onFolderDrop, - onFolderDragOver, - onFolderDragLeave, - onFolderDragStart, - onFolderDragEnd, - draggedFolderId, - onFolderRename, - selectedFolderIds = [], - selectedDocumentIds = [], - focusedRowKey, - draggingDocumentIds = [], - onDocumentDragStart, - onDocumentDragEnd, - onDocumentRename, - onEntryPointer = null, - onEntrySelection = null, - onInspectDocument = null, - tagLookupById, - activeCorrespondentIds = [], - onFocusedRowChange, - ensureAssetUrl = null, - getDocumentAsset = () => null, - onTagClick, - onCorrespondentClick, - isSearchLoading = false, - onDocumentTagDrop, - viewMode = 'list', - onViewModeChange, - onClearSelection, - selectedEntries = [], - showHeader = true, -}) => { - const showingSearchResults = searchResults !== null; - const rows = showingSearchResults ? searchResults : documents; - - const entries = useMemo(() => { - const list = []; - if (!showingSearchResults) { - subfolders.forEach((folder) => { - if (!folder || !folder.id) { - return; - } - list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); - }); - } - rows.forEach((doc) => { - if (!doc || !doc.id) { - return; - } - list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); - }); - return list; - }, [showingSearchResults, subfolders, rows]); - - const selectedSet = useMemo( - () => new Set(selectedDocumentIds), - [selectedDocumentIds], - ); - const selectedFolderSet = useMemo( - () => new Set(selectedFolderIds || []), - [selectedFolderIds], - ); - const draggingSet = useMemo( - () => new Set(draggingDocumentIds || []), - [draggingDocumentIds], - ); - const activeCorrespondentIdSet = useMemo( - () => new Set(activeCorrespondentIds || []), - [activeCorrespondentIds], - ); - const scrollRef = useRef(null); - const suppressDocumentClickRef = useRef(false); - const [, forceVisibilityTick] = useState(0); - const lastScrollNodeRef = useRef(null); - const assignScrollRef = useCallback((node) => { - if (lastScrollNodeRef.current === node) { - return; - } - lastScrollNodeRef.current = node; - scrollRef.current = node; - if (node) { - forceVisibilityTick((value) => value + 1); - } - }, []); - const isGridView = viewMode === 'grid'; - const isDeskView = viewMode === 'desk'; - - const [previewDocId, setPreviewDocId] = useState(null); - - const previewDoc = useMemo(() => { - if (!previewDocId) { - return null; - } - return rows.find((doc) => doc?.id === previewDocId) || null; - }, [previewDocId, rows]); - - useEffect(() => { - if (previewDocId && !previewDoc) { - setPreviewDocId(null); - } - }, [previewDocId, previewDoc]); - - const previewNavigator = useAssetNavigator({ - document: previewDoc, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); - - const { - currentUrl: previewUrl, - canGoPrev: previewCanGoPrev, - canGoNext: previewCanGoNext, - goPrev: previewGoPrev, - goNext: previewGoNext, - } = previewNavigator; - - const previewDisplay = useMemo(() => { - if (!previewDoc || !previewUrl) { - return null; - } - return { - url: previewUrl, - alt: previewDoc.title, - canGoPrev: Boolean(previewCanGoPrev), - canGoNext: Boolean(previewCanGoNext), - goPrev: previewGoPrev, - goNext: previewGoNext, - }; - }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); - - const closePreviewOverlay = useCallback(() => { - setPreviewDocId(null); - }, []); - - const handleDocumentPreviewZoom = useCallback( - (doc) => { - if (!doc || !doc.id) { - return; - } - const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; - if (!previewAsset) { - return; - } - setPreviewDocId(doc.id); - }, - [getDocumentAsset], - ); - - const handleDocumentActivate = useCallback( - (doc, event) => { - if (!doc) { - return; - } - if (event) { - if (typeof event.preventDefault === 'function') { - event.preventDefault(); - } - if (typeof event.stopPropagation === 'function') { - event.stopPropagation(); - } - } - if (event?.altKey) { - handleDocumentPreviewZoom(doc); - return; - } - onInspectDocument?.(doc.id, event); - }, - [handleDocumentPreviewZoom, onInspectDocument], - ); - - const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); - const navigableRows = useMemo( - () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), - [entries], - ); - const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); - - const getEntryByKey = useCallback( - (rowKey) => entries.find((entry) => entry.key === rowKey) || null, - [entries], - ); - - const handlePanelFocus = useCallback(() => { - let resolvedKey = null; - - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - resolvedKey = focusedRowKey; - } - - if (!resolvedKey) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - resolvedKey = candidate; - break; - } - } - } - - if (!resolvedKey && navigableRows.length) { - resolvedKey = navigableRows[0].key; - } - - if (!resolvedKey) { - return; - } - - onFocusedRowChange?.(resolvedKey); - - if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { - onEntrySelection(resolvedKey, { - shiftKey: false, - preventDefault: () => {}, - }); - } - }, [ - focusedRowKey, - navigableRowKeys, - navigableRows, - onEntrySelection, - onFocusedRowChange, - selectedEntries, - selectedRowKeySet, - ]); - - const handlePanelKeyDown = useCallback( - (event) => { - const { key, shiftKey } = event; - const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; - if (!triggers.includes(key)) { - return; - } - - if (!navigableRows.length) { - return; - } - - event.preventDefault(); - - let activeKey = - focusedRowKey && navigableRowKeys.includes(focusedRowKey) - ? focusedRowKey - : null; - - if (!activeKey) { - if (selectedEntries.length) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - activeKey = candidate; - break; - } - } - } - - if (!activeKey) { - activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; - } - } - - const currentIndex = navigableRowKeys.indexOf(activeKey); - const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; - - if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { - if (activeRow) { - onEntrySelection?.(activeRow.key, event); - if (activeRow.type === EntryType.folder) { - onFolderSelect?.(activeRow.id); - } else { - const entry = getEntryByKey(activeRow.key); - if (entry?.document) { - handleDocumentPreviewZoom(entry.document); - } - } - } - return; - } - - let nextIndex = currentIndex; - if (key === 'ArrowDown') { - nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); - } else if (key === 'ArrowUp') { - nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); - } else if (key === 'Home') { - nextIndex = 0; - } else if (key === 'End') { - nextIndex = navigableRows.length - 1; - } - - if (nextIndex === -1 || nextIndex >= navigableRows.length) { - return; - } - - const targetRow = navigableRows[nextIndex]; - if (!targetRow) { - return; - } - - onFocusedRowChange?.(targetRow.key); - onEntrySelection?.(targetRow.key, { - shiftKey, - preventDefault: () => {}, - }); - }, - [ - focusedRowKey, - getEntryByKey, - navigableRowKeys, - navigableRows, - onEntrySelection, - onFocusedRowChange, - onFolderSelect, - selectedEntries, - handleDocumentPreviewZoom, - ], - ); - - const isListView = viewMode === 'list'; - const gridIconSize = DEFAULT_GRID_ICON_SIZE; - const handleSetViewMode = useCallback( - (nextMode) => { - if (!onViewModeChange) { - return; - } - onViewModeChange(nextMode); - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, - [onViewModeChange], - ); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [viewMode]); - const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); - const ensureFocusedRowVisible = useCallback(() => { - if (!focusedRowKey) return; - const container = scrollRef.current; - if (!container) return; - let selector = null; - if (focusedRowKey.startsWith('document:')) { - selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; - } else if (focusedRowKey.startsWith('folder:')) { - selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; - } - if (!selector) { - return; - } - const row = container.querySelector(selector); - if (!row || !container.contains(row)) { - return; - } - - const header = container.querySelector('thead'); - const headerHeight = header ? header.getBoundingClientRect().height : 0; - const rowTop = row.offsetTop; - const rowBottom = rowTop + row.offsetHeight; - const visibleTop = container.scrollTop + headerHeight; - const visibleBottom = container.scrollTop + container.clientHeight; - - if (rowTop < visibleTop) { - container.scrollTop = Math.max(rowTop - headerHeight, 0); - return; - } - - if (rowBottom > visibleBottom) { - const nextScrollTop = rowBottom - container.clientHeight; - container.scrollTop = Math.max(nextScrollTop, 0); - } - }, [focusedRowKey]); - - useEffect(() => { - ensureFocusedRowVisible(); - }, [ensureFocusedRowVisible]); - - const activeDescendantId = useMemo(() => { - if (!focusedRowKey) return undefined; - if (focusedRowKey.startsWith('document:')) { - return `document-row-${focusedRowKey.slice('document:'.length)}`; - } - if (focusedRowKey.startsWith('folder:')) { - return `folder-row-${focusedRowKey.slice('folder:'.length)}`; - } - return undefined; - }, [focusedRowKey]); - - const handleDocumentTagDragOver = useCallback( - (event) => { - if (!isTagDragEvent(event)) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - event.currentTarget.classList.add('tag-drop-target'); - }, - [isTagDragEvent], - ); - - const handleDocumentTagDragLeave = useCallback( - (event) => { - if (!isTagDragEvent(event)) { - return; - } - if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { - return; - } - event.currentTarget.classList.remove('tag-drop-target'); - }, - [isTagDragEvent], - ); - - const handleDocumentTagDrop = useCallback( - (event, documentId) => { - if (!isTagDragEvent(event)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove('tag-drop-target'); - const payload = - event.dataTransfer.getData('application/x-papercrate-tag') || - event.dataTransfer.getData('text/papercrate-tag'); - if (!payload) { - return; - } - try { - const parsed = JSON.parse(payload); - if (parsed?.id && onDocumentTagDrop) { - onDocumentTagDrop(documentId, parsed); - } - } catch (error) { - console.warn('[documents] Failed to parse tag drop payload', error); - } - }, - [isTagDragEvent, onDocumentTagDrop], - ); - - const handleDocumentClick = useCallback( - (doc, event) => { - if (!doc || suppressDocumentClickRef.current) { - return; - } - - if (typeof onEntryPointer === 'function') { - onEntryPointer( - { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, - event, - ); - } - }, - [onEntryPointer], - ); - - const handleFolderClick = useCallback( - (folder, event) => { - if (!folder) { - return; - } - - if (typeof onEntryPointer === 'function') { - onEntryPointer( - { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, - event, - ); - } - - if ( - !isPointerModifierEvent(event) - && isPrimaryPointerEvent(event) - && scrollRef.current - ) { - scrollRef.current.focus({ preventScroll: true }); - onFocusedRowChange?.(`folder:${folder.id}`); - } - }, - [onEntryPointer, onFocusedRowChange], - ); - - const handleDocumentDragStartLocal = useCallback( - (event, doc) => { - suppressDocumentClickRef.current = true; - onDocumentDragStart?.(event, doc); - }, - [onDocumentDragStart], - ); - - const handleDocumentDragEndLocal = useCallback( - (event) => { - onDocumentDragEnd?.(event); - requestAnimationFrame(() => { - suppressDocumentClickRef.current = false; - }); - }, - [onDocumentDragEnd], - ); - - const hasDocumentEntries = useMemo( - () => entries.some((entry) => entry.type === EntryType.document), - [entries], - ); - const showTableRows = entries.length > 0; - const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; - const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; - const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; - const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); - const trailEntries = useMemo(() => { - if (!breadcrumbEntries.length) { - return [{ id: 'current-folder', label: currentFolderName }]; - } - const lastIndex = breadcrumbEntries.length - 1; - return breadcrumbEntries.map((crumb, index) => ({ - id: crumb.id ?? index, - label: crumb.name ?? crumb.label ?? crumb.title ?? '', - onClick: index < lastIndex && onFolderSelect - ? () => onFolderSelect(crumb.id) - : null, - })); - }, [breadcrumbEntries, currentFolderName, onFolderSelect]); - - return ( - <> -
- {showHeader ? ( -
-
-

- -

- {showingSearchResults && ( -
Search results
- )} -
-
-
- - - -
- -
-
- ) : null} - {showDefaultEmptyState ? ( -
-
- Drop files anywhere or onto a folder to upload documents. -
-
- ) : showGridSearchEmptyState ? ( -
-
- No documents match the current filters. -
-
- ) : showListSearchEmptyState ? ( -
-
No documents match the current filters.
-
- ) : ( -
-
{ - if (event.target === scrollRef.current) { - handlePanelFocus(); - } - }} - onKeyDown={(event) => { - if (event.target !== scrollRef.current) { - return; - } - handlePanelKeyDown(event); - }} - onClick={(event) => { - if (event.target === event.currentTarget) { - onClearSelection?.(); - } - }} - aria-activedescendant={isGridView ? undefined : activeDescendantId} - > - {isGridView ? ( - - ) : !showTableRows ? null : ( - - )} -
-
- )} -
- - - ); -}; - -export default DocumentsPanel; - -const SortFieldQuickMenu = ({ sortField, onChange }) => { - const currentOption = useMemo( - () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], - [sortField], - ); - - const options = useMemo( - () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), - [], - ); - - const handleSelect = useCallback( - (value, option) => { - if (typeof onChange !== 'function') { - return; - } - const nextValue = option?.id || option?.original?.id || value; - if (nextValue) { - onChange(nextValue); - } - }, - [onChange], - ); - - const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; - - return ( - - {label} - - )} - triggerAriaLabel={`Sort by ${label}`} - triggerTitle={`Sort by ${label}`} - placeholder="Select sort field" - menuMinWidth={200} - align="start" - positionStrategy="absolute" - /> - ); -}; - -export const createDocumentsTableHeaderActions = ({ - viewMode, - onViewModeChange, - onRefresh, - onShowDeskHelp = null, - sortField = 'title', - onSortFieldChange = null, - sortDirection = 'asc', - onSortDirectionToggle = null, - isFilterActive = false, - includeDescendants = true, - onToggleIncludeDescendants = null, -}) => { - const isListView = viewMode === 'list'; - const isGridView = viewMode === 'grid'; - const isDeskView = viewMode === 'desk'; - - const sortDirectionIsDesc = sortDirection === 'desc'; - const sortDirectionTitle = sortDirectionIsDesc - ? 'Sorting Z → A. Click to switch to ascending.' - : 'Sorting A → Z. Click to switch to descending.'; - const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' - ? ( - - ) - : null; - - const sortControls = typeof onSortFieldChange === 'function' - ? ( -
- - {typeof onSortDirectionToggle === 'function' ? ( - - ) : null} -
- ) - : null; - - return ( - <> - {isDeskView && typeof onShowDeskHelp === 'function' ? ( - <> - - - - ) : null} - {includeDescendantsToggle ? ( - <> - {includeDescendantsToggle} - - - ) : null} - {sortControls ? ( - <> - {sortControls} - - - ) : null} -
- - - -
- - - - ); -}; - -export const createDocumentsSurface = ({ - tableProps, - parentBreadcrumb, - onNavigateParent, - renderSidebarToggle, - detailProps, - detailOpen = false, -}) => { - const { - currentFolderName, - breadcrumbs, - searchResults, - isFilterActive, - viewMode, - onViewModeChange, - onRefresh, - sortField, - sortDirection, - onSortFieldChange, - onSortDirectionToggle, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - onInspectDocument, - } = tableProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - - const actions = createDocumentsTableHeaderActions({ - viewMode, - onViewModeChange, - onRefresh, - sortField, - onSortFieldChange, - sortDirection, - onSortDirectionToggle, - isFilterActive, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'documents', - variant: 'documents', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return surfaceConfig; -}; +export { default } from './panel/DocumentsPanel'; +export { default as createDocumentsSurface } from './panel/createDocumentsSurface'; +export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar'; diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.jsx new file mode 100644 index 0000000..a5d307b --- /dev/null +++ b/frontend/src/documents/panel/DocumentsPanel.jsx @@ -0,0 +1,725 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ViewListIcon, ViewGridIcon, IconFileStack } from '../../ui/icons'; +import BreadcrumbTrail from '../../ui/BreadcrumbTrail'; +import DocumentsGrid from '../DocumentsGrid'; +import DocumentsList from '../DocumentsList'; +import { isTagTransferEvent } from '../tagTransfer'; +import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; +import { useAssetNavigator } from '../../hooks/useAssetNavigator'; +import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; + +const DEFAULT_GRID_ICON_SIZE = 144; + +const EntryType = { + folder: 'folder', + document: 'document', +}; + +const DocumentsPanel = ({ + currentFolderName, + breadcrumbs, + onRefresh, + subfolders, + documents, + searchResults, + isFilterActive = false, + onFolderSelect, + onFolderDrop, + onFolderDragOver, + onFolderDragLeave, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onFolderRename, + selectedFolderIds = [], + selectedDocumentIds = [], + focusedRowKey, + draggingDocumentIds = [], + onDocumentDragStart, + onDocumentDragEnd, + onDocumentRename, + onEntryPointer = null, + onEntrySelection = null, + onInspectDocument = null, + tagLookupById, + activeCorrespondentIds = [], + onFocusedRowChange, + ensureAssetUrl = null, + getDocumentAsset = () => null, + onTagClick, + onCorrespondentClick, + isSearchLoading = false, + onDocumentTagDrop, + viewMode = 'list', + onViewModeChange, + onClearSelection, + selectedEntries = [], + showHeader = true, +}) => { + const showingSearchResults = searchResults !== null; + const rows = showingSearchResults ? searchResults : documents; + + const entries = useMemo(() => { + const list = []; + if (!showingSearchResults) { + subfolders.forEach((folder) => { + if (!folder || !folder.id) { + return; + } + list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); + }); + } + rows.forEach((doc) => { + if (!doc || !doc.id) { + return; + } + list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); + }); + return list; + }, [showingSearchResults, subfolders, rows]); + + const selectedSet = useMemo( + () => new Set(selectedDocumentIds), + [selectedDocumentIds], + ); + const selectedFolderSet = useMemo( + () => new Set(selectedFolderIds || []), + [selectedFolderIds], + ); + const draggingSet = useMemo( + () => new Set(draggingDocumentIds || []), + [draggingDocumentIds], + ); + const activeCorrespondentIdSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + const scrollRef = useRef(null); + const suppressDocumentClickRef = useRef(false); + const [, forceVisibilityTick] = useState(0); + const lastScrollNodeRef = useRef(null); + const assignScrollRef = useCallback((node) => { + if (lastScrollNodeRef.current === node) { + return; + } + lastScrollNodeRef.current = node; + scrollRef.current = node; + if (node) { + forceVisibilityTick((value) => value + 1); + } + }, []); + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const [previewDocId, setPreviewDocId] = useState(null); + + const previewDoc = useMemo(() => { + if (!previewDocId) { + return null; + } + return rows.find((doc) => doc?.id === previewDocId) || null; + }, [previewDocId, rows]); + + useEffect(() => { + if (previewDocId && !previewDoc) { + setPreviewDocId(null); + } + }, [previewDocId, previewDoc]); + + const previewNavigator = useAssetNavigator({ + document: previewDoc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + + const { + currentUrl: previewUrl, + canGoPrev: previewCanGoPrev, + canGoNext: previewCanGoNext, + goPrev: previewGoPrev, + goNext: previewGoNext, + } = previewNavigator; + + const previewDisplay = useMemo(() => { + if (!previewDoc || !previewUrl) { + return null; + } + return { + url: previewUrl, + alt: previewDoc.title, + canGoPrev: Boolean(previewCanGoPrev), + canGoNext: Boolean(previewCanGoNext), + goPrev: previewGoPrev, + goNext: previewGoNext, + }; + }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); + + const closePreviewOverlay = useCallback(() => { + setPreviewDocId(null); + }, []); + + const handleDocumentPreviewZoom = useCallback( + (doc) => { + if (!doc || !doc.id) { + return; + } + const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; + if (!previewAsset) { + return; + } + setPreviewDocId(doc.id); + }, + [getDocumentAsset], + ); + + const handleDocumentActivate = useCallback( + (doc, event) => { + if (!doc) { + return; + } + if (event) { + if (typeof event.preventDefault === 'function') { + event.preventDefault(); + } + if (typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + } + if (event?.altKey) { + handleDocumentPreviewZoom(doc); + return; + } + onInspectDocument?.(doc.id, event); + }, + [handleDocumentPreviewZoom, onInspectDocument], + ); + + const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); + const navigableRows = useMemo( + () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), + [entries], + ); + const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); + + const getEntryByKey = useCallback( + (rowKey) => entries.find((entry) => entry.key === rowKey) || null, + [entries], + ); + + const handlePanelFocus = useCallback(() => { + let resolvedKey = null; + + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + resolvedKey = focusedRowKey; + } + + if (!resolvedKey) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + resolvedKey = candidate; + break; + } + } + } + + if (!resolvedKey && navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + + if (!resolvedKey) { + return; + } + + onFocusedRowChange?.(resolvedKey); + + if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { + onEntrySelection(resolvedKey, { + shiftKey: false, + preventDefault: () => {}, + }); + } + }, [ + focusedRowKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + selectedEntries, + selectedRowKeySet, + ]); + + const handlePanelKeyDown = useCallback( + (event) => { + const { key, shiftKey } = event; + const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; + if (!triggers.includes(key)) { + return; + } + + if (!navigableRows.length) { + return; + } + + event.preventDefault(); + + let activeKey = + focusedRowKey && navigableRowKeys.includes(focusedRowKey) + ? focusedRowKey + : null; + + if (!activeKey) { + if (selectedEntries.length) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + activeKey = candidate; + break; + } + } + } + + if (!activeKey) { + activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; + } + } + + const currentIndex = navigableRowKeys.indexOf(activeKey); + const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + if (activeRow) { + onEntrySelection?.(activeRow.key, event); + if (activeRow.type === EntryType.folder) { + onFolderSelect?.(activeRow.id); + } else { + const entry = getEntryByKey(activeRow.key); + if (entry?.document) { + handleDocumentPreviewZoom(entry.document); + } + } + } + return; + } + + let nextIndex = currentIndex; + if (key === 'ArrowDown') { + nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); + } else if (key === 'ArrowUp') { + nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); + } else if (key === 'Home') { + nextIndex = 0; + } else if (key === 'End') { + nextIndex = navigableRows.length - 1; + } + + if (nextIndex === -1 || nextIndex >= navigableRows.length) { + return; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + onFocusedRowChange?.(targetRow.key); + onEntrySelection?.(targetRow.key, { + shiftKey, + preventDefault: () => {}, + }); + }, + [ + focusedRowKey, + getEntryByKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + onFolderSelect, + selectedEntries, + handleDocumentPreviewZoom, + ], + ); + + const isListView = viewMode === 'list'; + const gridIconSize = DEFAULT_GRID_ICON_SIZE; + const handleSetViewMode = useCallback( + (nextMode) => { + if (!onViewModeChange) { + return; + } + onViewModeChange(nextMode); + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, + [onViewModeChange], + ); + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [viewMode]); + const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); + const ensureFocusedRowVisible = useCallback(() => { + if (!focusedRowKey) return; + const container = scrollRef.current; + if (!container) return; + let selector = null; + if (focusedRowKey.startsWith('document:')) { + selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; + } else if (focusedRowKey.startsWith('folder:')) { + selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + if (!selector) { + return; + } + const row = container.querySelector(selector); + if (!row || !container.contains(row)) { + return; + } + + const header = container.querySelector('thead'); + const headerHeight = header ? header.getBoundingClientRect().height : 0; + const rowTop = row.offsetTop; + const rowBottom = rowTop + row.offsetHeight; + const visibleTop = container.scrollTop + headerHeight; + const visibleBottom = container.scrollTop + container.clientHeight; + + if (rowTop < visibleTop) { + container.scrollTop = Math.max(rowTop - headerHeight, 0); + return; + } + + if (rowBottom > visibleBottom) { + const nextScrollTop = rowBottom - container.clientHeight; + container.scrollTop = Math.max(nextScrollTop, 0); + } + }, [focusedRowKey]); + + useEffect(() => { + ensureFocusedRowVisible(); + }, [ensureFocusedRowVisible]); + + const activeDescendantId = useMemo(() => { + if (!focusedRowKey) return undefined; + if (focusedRowKey.startsWith('document:')) { + return `document-row-${focusedRowKey.slice('document:'.length)}`; + } + if (focusedRowKey.startsWith('folder:')) { + return `folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + return undefined; + }, [focusedRowKey]); + + const handleDocumentTagDragOver = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDragLeave = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { + return; + } + event.currentTarget.classList.remove('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDrop = useCallback( + (event, documentId) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('tag-drop-target'); + const payload = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + if (!payload) { + return; + } + try { + const parsed = JSON.parse(payload); + if (parsed?.id && onDocumentTagDrop) { + onDocumentTagDrop(documentId, parsed); + } + } catch (error) { + console.warn('[documents] Failed to parse tag drop payload', error); + } + }, + [isTagDragEvent, onDocumentTagDrop], + ); + + const handleDocumentClick = useCallback( + (doc, event) => { + if (!doc || suppressDocumentClickRef.current) { + return; + } + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, + event, + ); + } + }, + [onEntryPointer], + ); + + const handleFolderClick = useCallback( + (folder, event) => { + if (!folder) { + return; + } + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, + event, + ); + } + + if ( + !isPointerModifierEvent(event) + && isPrimaryPointerEvent(event) + && scrollRef.current + ) { + scrollRef.current.focus({ preventScroll: true }); + onFocusedRowChange?.(`folder:${folder.id}`); + } + }, + [onEntryPointer, onFocusedRowChange], + ); + + const handleDocumentDragStartLocal = useCallback( + (event, doc) => { + suppressDocumentClickRef.current = true; + onDocumentDragStart?.(event, doc); + }, + [onDocumentDragStart], + ); + + const handleDocumentDragEndLocal = useCallback( + (event) => { + onDocumentDragEnd?.(event); + requestAnimationFrame(() => { + suppressDocumentClickRef.current = false; + }); + }, + [onDocumentDragEnd], + ); + + const hasDocumentEntries = useMemo( + () => entries.some((entry) => entry.type === EntryType.document), + [entries], + ); + const showTableRows = entries.length > 0; + const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; + const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; + const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; + const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); + const trailEntries = useMemo(() => { + if (!breadcrumbEntries.length) { + return [{ id: 'current-folder', label: currentFolderName }]; + } + const lastIndex = breadcrumbEntries.length - 1; + return breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex && onFolderSelect + ? () => onFolderSelect(crumb.id) + : null, + })); + }, [breadcrumbEntries, currentFolderName, onFolderSelect]); + + return ( + <> +
+ {showHeader ? ( +
+
+

+ +

+ {showingSearchResults && ( +
Search results
+ )} +
+
+
+ + + +
+ +
+
+ ) : null} + {showDefaultEmptyState ? ( +
+
+ Drop files anywhere or onto a folder to upload documents. +
+
+ ) : showGridSearchEmptyState ? ( +
+
+ No documents match the current filters. +
+
+ ) : showListSearchEmptyState ? ( +
+
No documents match the current filters.
+
+ ) : ( +
+
{ + if (event.target === scrollRef.current) { + handlePanelFocus(); + } + }} + onKeyDown={(event) => { + if (event.target !== scrollRef.current) { + return; + } + handlePanelKeyDown(event); + }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} + aria-activedescendant={isGridView ? undefined : activeDescendantId} + > + {isGridView ? ( + + ) : !showTableRows ? null : ( + + )} +
+
+ )} +
+ + + ); +}; + +export default DocumentsPanel; diff --git a/frontend/src/documents/panel/DocumentsToolbar.jsx b/frontend/src/documents/panel/DocumentsToolbar.jsx new file mode 100644 index 0000000..e593be0 --- /dev/null +++ b/frontend/src/documents/panel/DocumentsToolbar.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { + ViewListIcon, + ViewGridIcon, + IconFileStack, + RefreshIcon, + MinusVerticalIcon, + InfoIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, +} from '../../ui/icons'; +import SortFieldQuickMenu from './SortFieldQuickMenu'; + +export const createDocumentsTableHeaderActions = ({ + viewMode, + onViewModeChange, + onRefresh, + onShowDeskHelp = null, + sortField = 'title', + onSortFieldChange = null, + sortDirection = 'asc', + onSortDirectionToggle = null, + isFilterActive = false, + includeDescendants = true, + onToggleIncludeDescendants = null, +}) => { + const isListView = viewMode === 'list'; + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const sortDirectionIsDesc = sortDirection === 'desc'; + const sortDirectionTitle = sortDirectionIsDesc + ? 'Sorting Z → A. Click to switch to ascending.' + : 'Sorting A → Z. Click to switch to descending.'; + + const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' + ? ( + + ) + : null; + + const sortControls = typeof onSortFieldChange === 'function' + ? ( +
+ + {typeof onSortDirectionToggle === 'function' ? ( + + ) : null} +
+ ) + : null; + + return ( + <> + {isDeskView && typeof onShowDeskHelp === 'function' ? ( + <> + + + + ) : null} + {includeDescendantsToggle ? ( + <> + {includeDescendantsToggle} + + + ) : null} + {sortControls ? ( + <> + {sortControls} + + + ) : null} +
+ + + +
+ + + + ); +}; + +export default createDocumentsTableHeaderActions; diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.jsx b/frontend/src/documents/panel/SortFieldQuickMenu.jsx new file mode 100644 index 0000000..35d5631 --- /dev/null +++ b/frontend/src/documents/panel/SortFieldQuickMenu.jsx @@ -0,0 +1,64 @@ +import React, { useCallback, useMemo } from 'react'; +import QuickAddMenu from '../../ui/QuickAddMenu'; + +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +]; + +const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => { + const next = acc; + next[option.value] = option.label; + return next; +}, {}); + +const SortFieldQuickMenu = ({ sortField, onChange }) => { + const currentOption = useMemo( + () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], + [sortField], + ); + + const options = useMemo( + () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), + [], + ); + + const handleSelect = useCallback( + (value, option) => { + if (typeof onChange !== 'function') { + return; + } + const nextValue = option?.id || option?.original?.id || value; + if (nextValue) { + onChange(nextValue); + } + }, + [onChange], + ); + + const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; + + return ( + + {label} + + )} + triggerAriaLabel={`Sort by ${label}`} + triggerTitle={`Sort by ${label}`} + placeholder="Select sort field" + menuMinWidth={200} + align="start" + positionStrategy="absolute" + /> + ); +}; + +export default SortFieldQuickMenu; diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.js new file mode 100644 index 0000000..1124db1 --- /dev/null +++ b/frontend/src/documents/panel/createDocumentsSurface.js @@ -0,0 +1,119 @@ +import React from 'react'; +import DetailPanel from '../../detail/DetailPanel'; +import SelectionFloatingActions from '../SelectionFloatingActions'; +import createWorkspaceSurfaceConfig from '../workspaceHeader'; +import DocumentsPanel from './DocumentsPanel'; +import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; + +const createDocumentsSurface = ({ + tableProps, + parentBreadcrumb, + onNavigateParent, + renderSidebarToggle, + detailProps, + detailOpen = false, +}) => { + const { + currentFolderName, + breadcrumbs, + searchResults, + isFilterActive, + viewMode, + onViewModeChange, + onRefresh, + sortField, + sortDirection, + onSortFieldChange, + onSortDirectionToggle, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + onInspectDocument, + } = tableProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + + const actions = createDocumentsTableHeaderActions({ + viewMode, + onViewModeChange, + onRefresh, + sortField, + onSortFieldChange, + sortDirection, + onSortDirectionToggle, + isFilterActive, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + + return createWorkspaceSurfaceConfig({ + key: 'documents', + variant: 'documents', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); +}; + +export default createDocumentsSurface; From 20cf597c5c4070b0422e5df364d99641eef29927 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 9 Nov 2025 20:21:46 +0100 Subject: [PATCH 02/14] refactor --- frontend/src/app/useEntryPointerHandler.js | 48 ---------------- frontend/src/desktop/math.js | 6 +- frontend/src/detail/PreviewZoomOverlay.jsx | 7 +-- .../src/documents/DocumentSummarySection.jsx | 52 +++-------------- frontend/src/documents/DocumentsList.jsx | 12 +--- frontend/src/documents/documentMetadata.js | 8 +-- frontend/src/documents/documentSummary.js | 12 +--- frontend/src/documents/useEntryPointer.js | 14 +++-- .../hooks/documents/useDocumentsWorkspace.js | 2 +- frontend/src/ui/PanelHeader.jsx | 3 +- frontend/src/ui/classNames.js | 3 + frontend/src/ui/icons.js | 3 +- frontend/src/ui/useFloatingMenu.js | 3 +- frontend/src/utils/date.js | 57 +++++++++++++++++++ frontend/src/utils/math.js | 13 +++++ 15 files changed, 101 insertions(+), 142 deletions(-) delete mode 100644 frontend/src/app/useEntryPointerHandler.js create mode 100644 frontend/src/ui/classNames.js create mode 100644 frontend/src/utils/date.js create mode 100644 frontend/src/utils/math.js diff --git a/frontend/src/app/useEntryPointerHandler.js b/frontend/src/app/useEntryPointerHandler.js deleted file mode 100644 index 3fa1c29..0000000 --- a/frontend/src/app/useEntryPointerHandler.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback } from 'react'; -import { useEntryPointerHandler as useEntryPointerCore, isPointerModifierEvent, isPrimaryPointerEvent } from '../documents/useEntryPointer'; - -export const useEntryPointer = ({ - resolveDocumentRowKey, - resolveFolderRowKey, - onSelectDocument, - onInspectDocument, - onSelectFolder, -}) => { - const coreHandler = useEntryPointerCore({ - resolveDocumentRowKey, - resolveFolderRowKey, - onSelectDocument: (documentId, event, meta) => { - const { modifierClick, primaryClick, rowKey } = meta; - onSelectDocument(documentId, event, { modifierClick, primaryClick, rowKey }); - if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { - onInspectDocument(documentId, meta); - } - }, - onSelectFolder, - }); - - return useCallback((entry, event) => { - if (!entry) { - return; - } - if (entry.type !== 'document') { - coreHandler(entry, event); - return; - } - - const modifierClick = isPointerModifierEvent(event); - const primaryClick = isPrimaryPointerEvent(event); - - onSelectDocument(entry.id, event, { - modifierClick, - primaryClick, - rowKey: entry.key, - }); - - if (!modifierClick && primaryClick) { - onInspectDocument?.(entry.id, { modifierClick, primaryClick, rowKey: entry.key }); - } - }, [coreHandler, onInspectDocument, onSelectDocument]); -}; - -export default useEntryPointer; diff --git a/frontend/src/desktop/math.js b/frontend/src/desktop/math.js index 8854f38..7c26bad 100644 --- a/frontend/src/desktop/math.js +++ b/frontend/src/desktop/math.js @@ -1,8 +1,4 @@ -export const clamp = (value, min, max) => { - if (value < min) return min; - if (value > max) return max; - return value; -}; +export { clamp } from '../utils/math'; export const formatTransform = (x, y, rotation = 0, scale = 1) => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx index 384ff61..2426f2c 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -1,15 +1,10 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; +import { clamp } from '../utils/math'; const noop = () => {}; -const clamp = (value, min, max) => { - if (value < min) return min; - if (value > max) return max; - return value; -}; - const ensureDocumentRoot = () => { if (typeof document === 'undefined') { return null; diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.jsx index 5d16acb..b040c32 100644 --- a/frontend/src/documents/DocumentSummarySection.jsx +++ b/frontend/src/documents/DocumentSummarySection.jsx @@ -2,50 +2,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EditIcon, IconX, PlusIcon } from '../ui/icons'; import QuickAddMenu from '../ui/QuickAddMenu'; import { getTagColorStyle } from '../utils/colors'; +import { + formatDate, + toDateInputValue, + toIssuedTimestamp, +} from '../utils/date'; import { describeDocumentSummary } from './documentSummary'; -const formatDate = (value) => { - if (!value) { - return null; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return null; - } - return date.toLocaleDateString(); -}; - -const toDateInputValue = (value) => { - if (!value) { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - const timezoneOffset = date.getTimezoneOffset(); - const localDate = new Date(date.getTime() - timezoneOffset * 60000); - return localDate.toISOString().slice(0, 10); -}; - -const toIssuedTimestamp = (dateString, fallback) => { - if (!dateString) { - return null; - } - const base = fallback ? new Date(fallback) : new Date(); - if (Number.isNaN(base.getTime())) { - return null; - } - const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10)); - if (!year || !month || !day) { - return null; - } - - const candidate = new Date(base); - candidate.setUTCFullYear(year, month - 1, day); - return candidate.toISOString(); -}; - export const sortCorrespondents = (entries = []) => entries .filter((entry) => entry && entry.name) @@ -257,7 +220,10 @@ const DocumentSummarySection = ({ } return describeDocumentSummary(document); }, [document]); - const issuedDateLabel = useMemo(() => formatDate(document?.issued_at), [document?.issued_at]); + const issuedDateLabel = useMemo( + () => formatDate(document?.issued_at, { fallback: null }), + [document?.issued_at], + ); const editableTitle = Boolean(document && onUpdateTitle); const editableIssued = Boolean(document && onUpdateIssued); diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.jsx index 9811e99..714b2d8 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.jsx @@ -1,23 +1,13 @@ import React from 'react'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; +import { formatDate } from '../utils/date'; import DocumentThumbnailImage from './DocumentThumbnailImage'; import CorrespondentLinks from './CorrespondentLinks'; import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; import useInlineRename from './useInlineRename'; -const formatDate = (value) => { - if (!value) { - return "—"; - } - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return "—"; - } - return new Date(timestamp).toLocaleDateString(); -}; - const DocumentsList = ({ entries, focusedRowKey, diff --git a/frontend/src/documents/documentMetadata.js b/frontend/src/documents/documentMetadata.js index 0c57e3c..ad6f8f6 100644 --- a/frontend/src/documents/documentMetadata.js +++ b/frontend/src/documents/documentMetadata.js @@ -1,10 +1,4 @@ -const formatDateTime = (value) => { - if (!value) { - return '—'; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); -}; +import { formatDateTime } from '../utils/date'; export const buildDocumentMetadataItems = (document) => { if (!document) { diff --git a/frontend/src/documents/documentSummary.js b/frontend/src/documents/documentSummary.js index 47d473e..7243a88 100644 --- a/frontend/src/documents/documentSummary.js +++ b/frontend/src/documents/documentSummary.js @@ -1,15 +1,5 @@ import { formatFileSize } from '../utils/format'; - -const defaultFormatDateTime = (value) => { - if (!value) { - return '—'; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return '—'; - } - return date.toLocaleString(); -}; +import { formatDateTime as defaultFormatDateTime } from '../utils/date'; const coercePageCount = (metadata) => { const raw = metadata?.page_count; diff --git a/frontend/src/documents/useEntryPointer.js b/frontend/src/documents/useEntryPointer.js index f2a4eea..826645f 100644 --- a/frontend/src/documents/useEntryPointer.js +++ b/frontend/src/documents/useEntryPointer.js @@ -14,11 +14,12 @@ export const isPrimaryPointerEvent = (event) => { return type === 'click' || type === 'pointerdown' || type === 'pointerup'; }; -export const useEntryPointerHandler = ({ +export const useEntryPointer = ({ resolveDocumentRowKey, resolveFolderRowKey, onSelectDocument, onSelectFolder, + onInspectDocument, }) => useCallback( (entry, event) => { @@ -39,16 +40,20 @@ export const useEntryPointerHandler = ({ const modifierClick = isPointerModifierEvent(event); const primaryClick = isPrimaryPointerEvent(event); + const metadata = { modifierClick, primaryClick, rowKey }; if (type === 'document') { if (typeof onSelectDocument === 'function') { - onSelectDocument(id, event, { modifierClick, primaryClick, rowKey }); + onSelectDocument(id, event, metadata); + } + if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { + onInspectDocument(id, metadata); } return; } if (typeof onSelectFolder === 'function') { - onSelectFolder(id, event, { modifierClick, primaryClick, rowKey }); + onSelectFolder(id, event, metadata); } }, [ @@ -56,7 +61,8 @@ export const useEntryPointerHandler = ({ resolveFolderRowKey, onSelectDocument, onSelectFolder, + onInspectDocument, ], ); -export default useEntryPointerHandler; +export default useEntryPointer; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index 28eb0cb..86ae836 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -12,7 +12,7 @@ import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; import { api, useAppDispatch, useAppState } from '../../app/appState'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; -import { useEntryPointerHandler as useEntryPointerCore } from '../../documents/useEntryPointer'; +import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; import useDocumentsSelection from '../../documents/hooks/useDocumentsSelection'; import useBulkDocumentActions from '../../documents/hooks/useBulkDocumentActions'; diff --git a/frontend/src/ui/PanelHeader.jsx b/frontend/src/ui/PanelHeader.jsx index e7bad5f..5aa1e79 100644 --- a/frontend/src/ui/PanelHeader.jsx +++ b/frontend/src/ui/PanelHeader.jsx @@ -1,6 +1,5 @@ import React from 'react'; - -const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); +import composeClassName from './classNames'; const PanelHeader = ({ className = '', diff --git a/frontend/src/ui/classNames.js b/frontend/src/ui/classNames.js new file mode 100644 index 0000000..a9bd605 --- /dev/null +++ b/frontend/src/ui/classNames.js @@ -0,0 +1,3 @@ +export const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); + +export default composeClassName; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index 6b4cb25..dd8292a 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -41,8 +41,7 @@ import { IconFileInfo, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; - -const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); +import composeClassName from './classNames'; export const ChevronIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( Math.min(Math.max(value, min), max); - const resolveViewportWidth = () => { if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') { return window.innerWidth; diff --git a/frontend/src/utils/date.js b/frontend/src/utils/date.js new file mode 100644 index 0000000..955158a --- /dev/null +++ b/frontend/src/utils/date.js @@ -0,0 +1,57 @@ +const ensureDate = (value) => { + if (!value) { + return null; + } + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const formatDate = (value, { fallback = '—', locale, options } = {}) => { + const date = ensureDate(value); + if (!date) { + return fallback; + } + return date.toLocaleDateString(locale, options); +}; + +export const formatDateTime = (value, { fallback = '—', locale, options } = {}) => { + const date = ensureDate(value); + if (!date) { + return fallback; + } + return date.toLocaleString(locale, options); +}; + +export const toDateInputValue = (value) => { + const date = ensureDate(value); + if (!date) { + return ''; + } + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60000); + return localDate.toISOString().slice(0, 10); +}; + +export const toIssuedTimestamp = (dateString, fallback) => { + if (!dateString) { + return null; + } + const base = ensureDate(fallback) || new Date(); + const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10)); + if (!year || !month || !day) { + return null; + } + const candidate = new Date(base); + candidate.setUTCFullYear(year, month - 1, day); + return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString(); +}; + +export const parseDateValue = (value) => ensureDate(value); + +export default { + formatDate, + formatDateTime, + toDateInputValue, + toIssuedTimestamp, + parseDateValue, +}; diff --git a/frontend/src/utils/math.js b/frontend/src/utils/math.js new file mode 100644 index 0000000..f7ab48f --- /dev/null +++ b/frontend/src/utils/math.js @@ -0,0 +1,13 @@ +export const clamp = (value, min, max) => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +export default { + clamp, +}; From ac24297a7bc2dbc6e99fd81c43e88264bb0787e5 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 10 Nov 2025 00:32:41 +0100 Subject: [PATCH 03/14] upload button --- .../src/hooks/documents/useDocumentUploads.js | 106 ++++++++++++------ .../hooks/documents/useDocumentsWorkspace.js | 2 + frontend/src/sidebar/Sidebar.jsx | 74 +++++++++--- frontend/src/sidebar/useSidebarProps.js | 12 ++ frontend/src/ui/icons.js | 10 ++ 5 files changed, 153 insertions(+), 51 deletions(-) diff --git a/frontend/src/hooks/documents/useDocumentUploads.js b/frontend/src/hooks/documents/useDocumentUploads.js index a04bd22..373e40f 100644 --- a/frontend/src/hooks/documents/useDocumentUploads.js +++ b/frontend/src/hooks/documents/useDocumentUploads.js @@ -2,6 +2,26 @@ import { useCallback, useRef, useState } from 'react'; import useFileDrop from './useFileDrop'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +const mapFilesToEntries = (filesInput) => { + if (!filesInput) { + return []; + } + const files = Array.isArray(filesInput) ? filesInput : Array.from(filesInput); + return files + .filter(Boolean) + .map((file) => { + const relativePath = + typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : ''; + const segments = relativePath + ? relativePath + .split('/') + .slice(0, -1) + .filter(Boolean) + : []; + return { file, segments }; + }); +}; + const useDocumentUploads = ({ apiClient, token, @@ -34,23 +54,27 @@ const useDocumentUploads = ({ formData.append('folder_id', targetFolderId); } - try { - const { data, status } = await apiClient.post('/documents', formData); - const duplicate = data?.reused || status === 200; - setStatusMessage( - duplicate - ? `${file.name} already exists; reused existing document.` - : `Uploaded ${file.name}`, - duplicate ? 'info' : 'success', - ); - return data; - } catch (error) { - const message = error.response?.data?.error || `Failed to upload ${file.name}.`; - notifyApiError(error, message); - throw error; + try { + const { data, status } = await apiClient.post('/documents', formData); + const duplicate = data?.reused || status === 200; + setStatusMessage( + duplicate + ? `${file.name} already exists; reused existing document.` + : `Uploaded ${file.name}`, + duplicate ? 'info' : 'success', + ); + return data; + } catch (error) { + if (error.response?.status === 409) { + setStatusMessage(`${file.name} already exists on the server.`, 'info'); + return null; } - }, - [apiClient, notifyApiError, setStatusMessage], + const message = error.response?.data?.error || `Failed to upload ${file.name}.`; + notifyApiError(error, message); + throw error; + } + }, + [apiClient, notifyApiError, setStatusMessage], ); const ensureFolderPathOnServer = useCallback( @@ -200,35 +224,27 @@ const useDocumentUploads = ({ return results; }, []); - const handleFileDrop = useCallback( - async (dataTransfer, targetFolderId) => { + const uploadFileEntries = useCallback( + async (entries, targetFolderId) => { if (!token) { setStatusMessage('Please log in before uploading.', 'error'); return; } + if (!entries || !entries.length) { + setStatusMessage('No files to upload.', 'info'); + return; + } + setLoading(true); try { folderPathCacheRef.current.clear(); - let extracted; - try { - extracted = await extractFilesFromDataTransfer(dataTransfer); - } catch (error) { - const message = error.message || 'Failed to process dropped files.'; - notifyApiError(error, message); - return; - } - - if (!extracted.length) { - setStatusMessage('No files to upload.', 'info'); - return; - } const baseFolderId = targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; - for (const { file, segments } of extracted) { + for (const { file, segments } of entries) { // eslint-disable-next-line no-await-in-loop const destinationId = segments.length ? await ensureFolderPathOnServer(baseFolderId, segments) @@ -259,7 +275,6 @@ const useDocumentUploads = ({ }, [ token, - extractFilesFromDataTransfer, ensureFolderPathOnServer, uploadFile, refreshCurrentFolder, @@ -271,6 +286,30 @@ const useDocumentUploads = ({ ], ); + const handleFileDrop = useCallback( + async (dataTransfer, targetFolderId) => { + let extracted; + try { + extracted = await extractFilesFromDataTransfer(dataTransfer); + } catch (error) { + const message = error.message || 'Failed to process dropped files.'; + notifyApiError(error, message); + return; + } + + await uploadFileEntries(extracted, targetFolderId); + }, + [extractFilesFromDataTransfer, uploadFileEntries, notifyApiError], + ); + + const handleFileSelection = useCallback( + async (files, targetFolderId) => { + const entries = mapFilesToEntries(files); + await uploadFileEntries(entries, targetFolderId); + }, + [uploadFileEntries], + ); + useFileDrop({ shellRef, token, @@ -294,6 +333,7 @@ const useDocumentUploads = ({ setDropOverlayState, dragCounterRef, handleFileDrop, + handleFileSelection, uploadFile, extractFilesFromDataTransfer, resetUploadsState, diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index 86ae836..1c7878e 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -533,6 +533,7 @@ const useDocumentsWorkspace = ({ const { dropOverlayState, handleFileDrop, + handleFileSelection, resetUploadsState, } = useDocumentUploads({ apiClient: api, @@ -1371,6 +1372,7 @@ const useDocumentsWorkspace = ({ currentTenantId, handleTenantSelect, openSettings, + handleFileSelection, }); diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index ac576af..d258ed8 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -16,6 +16,7 @@ import { SunIcon, MoonIcon, DesktopIcon, + UploadIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; import useFloatingMenu from '../ui/useFloatingMenu'; @@ -187,6 +188,8 @@ const Sidebar = ({ activeTenantId = null, onSelectTenant, onOpenSettings, + onUploadFiles, + loading = false, }) => { const { setCollapsed, @@ -197,6 +200,7 @@ const Sidebar = ({ cycleThemeMode, themeModes, } = useSidebarContext(); + const uploadInputRef = useRef(null); const handleCollapse = useCallback(() => { setCollapsed(true); }, [setCollapsed]); @@ -258,6 +262,26 @@ const Sidebar = ({ onCreateFolder?.(); }, [creatingFolder, onCreateFolder]); + const handleUploadButtonClick = useCallback(() => { + if (!onUploadFiles || !uploadInputRef.current) { + return; + } + uploadInputRef.current.click(); + }, [onUploadFiles]); + + const handleUploadInputChange = useCallback( + (event) => { + const files = event.target?.files; + if (files && files.length && onUploadFiles) { + onUploadFiles(files, selectedFolder); + } + if (event.target) { + event.target.value = ''; + } + }, + [onUploadFiles, selectedFolder], + ); + const handleNeutralHueReset = useCallback(() => { resetNeutralHue(); }, [resetNeutralHue]); @@ -448,6 +472,13 @@ const Sidebar = ({ return (