From 934fb6b6898830dbad9d2da0df1de909805cfb5c Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 5 Nov 2025 12:29:30 +0100 Subject: [PATCH] desktop redo --- frontend/src/DesktopWorkspace.jsx | 378 ++++++++++++------- frontend/src/detail/DetailPanel.jsx | 115 +++++- frontend/src/documents/DocumentInfoPanel.jsx | 302 +++++++++++++++ frontend/src/documents/DocumentsPanel.jsx | 27 +- frontend/src/documents/documentMetadata.js | 49 +++ frontend/src/preview/DocumentViewerPanel.jsx | 279 ++++---------- frontend/src/styles.css | 5 + 7 files changed, 795 insertions(+), 360 deletions(-) create mode 100644 frontend/src/documents/DocumentInfoPanel.jsx create mode 100644 frontend/src/documents/documentMetadata.js diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 01e2899..c445c37 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -39,52 +39,139 @@ const CARD_MAX = 340; const TAG_REMOVE_DISTANCE = 160; const STACK_HIT_EPSILON = 4; const POINTER_DRAG_THRESHOLD_SQUARED = 16; +const LONG_PRESS_DURATION_MS = 450; const DEBUG_DRAG = false; const DEBUG_FOCUS = true; const DEBUG_DROP = true; -const resolveDeskPointerIntent = ({ - alreadySelected = false, - selectedCount = 0, - stackDocIds = null, - metaOrCtrl = false, - pointerButton = 0, +const CLICK_ACTIONS = { + selectSingle: 'selectSingle', + openDetail: 'openDetail', + addCard: 'addCard', + addStack: 'addStack', + none: 'none', +}; + +const DRAG_ACTIONS = { + dragSelectSingle: 'dragSelectSingle', + dragSelection: 'dragSelection', + dragSelectStack: 'dragSelectStack', + none: 'none', +}; + +const createPointerIntent = ({ + doc, + entryDescriptor, + selectedDocumentIds, + metaKey, + pointerButton, + pointerType, + stackHits, }) => { - const stackList = Array.isArray(stackDocIds) && stackDocIds.length > 0 ? [...stackDocIds] : null; + const alreadySelected = selectedDocumentIds.includes(doc.id); + const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - if (!metaOrCtrl) { - return { - callEntryPointer: true, - skipSelection: false, - stackDragDocIds: null, - stackClickDocIds: null, - stackReplace: false, - openDetailOnRelease: alreadySelected && pointerButton === 0 && selectedCount > 0, - }; + let clickAction = CLICK_ACTIONS.none; + let dragAction = DRAG_ACTIONS.none; + + if (metaKey) { + clickAction = CLICK_ACTIONS.addStack; + dragAction = DRAG_ACTIONS.dragSelectStack; + } else if (alreadySelected) { + clickAction = CLICK_ACTIONS.openDetail; + dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle; + } else { + clickAction = CLICK_ACTIONS.selectSingle; + dragAction = DRAG_ACTIONS.dragSelectSingle; } - if (alreadySelected) { - return { - callEntryPointer: false, - skipSelection: true, - stackDragDocIds: stackList, - stackClickDocIds: stackList, - stackReplace: Boolean(stackList), - openDetailOnRelease: false, - }; - } + const stackList = Array.isArray(stackHits) && stackHits.length > 0 + ? stackHits.slice() + : [String(doc.id)]; + + const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null; + const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null; return { - callEntryPointer: true, - skipSelection: false, - stackDragDocIds: stackList, - stackClickDocIds: null, - stackReplace: Boolean(stackList), - openDetailOnRelease: false, + docId: doc.id, + entryDescriptor, + pointerType, + pointerButton, + selectedAtDown: alreadySelected, + selectionCountAtDown: selectionCount, + metaKey, + clickAction, + dragAction, + stackDocIdsForDrag, + stackDocIdsForClick, + stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack, + stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack, + clickSelectionApplied: false, + stackSelectionApplied: false, + longPressTriggered: false, }; }; +const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { + switch (intent.clickAction) { + case CLICK_ACTIONS.selectSingle: + case CLICK_ACTIONS.addCard: + if (typeof onEntryPointer === 'function') { + onEntryPointer(intent.entryDescriptor, event); + } + intent.clickSelectionApplied = true; + break; + case CLICK_ACTIONS.addStack: + if ( + Array.isArray(intent.stackDocIdsForClick) + && intent.stackDocIdsForClick.length > 0 + && typeof onDocumentStackSelect === 'function' + ) { + onDocumentStackSelect(intent.stackDocIdsForClick, event, { replace: intent.stackReplaceOnClick }); + intent.clickSelectionApplied = true; + intent.stackSelectionApplied = true; + } + break; + case CLICK_ACTIONS.openDetail: + default: + intent.clickSelectionApplied = true; + break; + } +}; + +const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { + if (!intent || intent.clickSelectionApplied) { + return; + } + + applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect }); +}; + +const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => { + if (!intent) { + return; + } + + const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0 + ? stackDocIds.slice() + : [intent.docId]; + + if (typeof onDocumentStackSelect === 'function') { + onDocumentStackSelect(stackCopy, syntheticEvent, { replace: true }); + } + + intent.clickAction = CLICK_ACTIONS.addStack; + intent.dragAction = DRAG_ACTIONS.dragSelectStack; + intent.stackDocIdsForClick = stackCopy; + intent.stackDocIdsForDrag = stackCopy; + intent.stackReplaceOnClick = true; + intent.stackReplaceOnDrag = true; + intent.clickSelectionApplied = true; + intent.stackSelectionApplied = true; + intent.longPressTriggered = true; +}; + const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax); const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => { @@ -2006,10 +2093,11 @@ const DesktopWorkspaceView = () => { const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag(); - const deferredSelectionRef = useRef(null); const pointerIntentRef = useRef(null); const pointerStartRef = useRef({ x: 0, y: 0 }); const pointerMovedRef = useRef(false); + const longPressTimerRef = useRef(null); + const longPressActiveRef = useRef(false); const resolveStackDocIds = useCallback( (event, targetDocId = null) => { @@ -2145,6 +2233,58 @@ const DesktopWorkspaceView = () => { const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); + const resetLongPressState = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + longPressActiveRef.current = false; + }, []); + + const scheduleLongPress = useCallback( + ({ doc, modifierActive, pointerType }) => { + if (modifierActive || pointerType !== 'touch') { + longPressActiveRef.current = false; + return; + } + + longPressActiveRef.current = true; + if (typeof window === 'undefined') { + return; + } + + longPressTimerRef.current = window.setTimeout(() => { + if (!longPressActiveRef.current || pointerMovedRef.current) { + resetLongPressState(); + return; + } + + const intent = pointerIntentRef.current; + if (!intent || intent.docId !== doc.id) { + resetLongPressState(); + return; + } + + const syntheticEvent = { + clientX: pointerStartRef.current.x, + clientY: pointerStartRef.current.y, + }; + const stackHits = resolveStackDocIds(syntheticEvent, doc.id); + applyLongPressSelection({ + intent, + stackDocIds: stackHits, + syntheticEvent, + onDocumentStackSelect, + }); + pointerIntentRef.current = intent; + resetLongPressState(); + }, LONG_PRESS_DURATION_MS); + }, + [onDocumentStackSelect, resetLongPressState, resolveStackDocIds], + ); + + useEffect(() => () => resetLongPressState(), [resetLongPressState]); + const handleShellKeyDown = useCallback( (event) => { if (!event || event.defaultPrevented) { @@ -2293,75 +2433,56 @@ const DesktopWorkspaceView = () => { y: Number.isFinite(event.clientY) ? event.clientY : 0, }; pointerMovedRef.current = false; - const selectionCountAtDown = Array.isArray(selectedDocumentIds) - ? selectedDocumentIds.length - : 0; - const alreadySelected = selectedDocumentIds.includes(doc.id); - const metaOrCtrlOnly = - (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; + resetLongPressState(); - const modifierActive = - Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); const pointerButton = typeof event.button === 'number' ? event.button : 0; - const stackHits = metaOrCtrlOnly ? resolveStackDocIds(event, doc.id) : null; - const pointerIntent = resolveDeskPointerIntent({ - alreadySelected, - selectedCount: selectionCountAtDown, - stackDocIds: stackHits, - metaOrCtrl: metaOrCtrlOnly, - pointerButton, - }); + const pointerType = typeof event.pointerType === 'string' ? event.pointerType : ''; + const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; + const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); - const stackDragDocIds = pointerIntent.stackDragDocIds; - const stackClickDocIds = pointerIntent.stackClickDocIds; - - if (alreadySelected && typeof onPromoteSelection === 'function') { - onPromoteSelection(doc.id, event); - } - pointerIntentRef.current = { - docId: doc.id, - selectedAtDown: alreadySelected, - selectionCountAtDown, - modifierActive, - pointerButton, - openDetailOnRelease: - pointerIntent.openDetailOnRelease && typeof onDocumentOpen === 'function', - stackDragDocIds, - stackClickDocIds, - stackClickApplied: false, + const entryDescriptor = { + type: 'document', + id: doc.id, + key: `document:${doc.id}`, }; - const deferSelection = - !modifierActive - && alreadySelected - && Array.isArray(selectedDocumentIds) - && selectedDocumentIds.length > 1; + const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; - const skipPointerSelection = pointerIntent.skipSelection; + const intent = createPointerIntent({ + doc, + entryDescriptor, + selectedDocumentIds, + metaKey, + pointerButton, + pointerType, + stackHits, + }); - if (skipPointerSelection) { - deferredSelectionRef.current = null; - } else if (deferSelection) { - deferredSelectionRef.current = { - entry: { type: 'document', id: doc.id, key: `document:${doc.id}` }, - applySelection: false, - }; - } else { - deferredSelectionRef.current = null; - if (pointerIntent.callEntryPointer && typeof onEntryPointer === 'function') { - onEntryPointer( - { type: 'document', id: doc.id, key: `document:${doc.id}` }, - event, - ); - } + if (intent.selectedAtDown && typeof onPromoteSelection === 'function') { + onPromoteSelection(doc.id, event); } + applyClickPlanImmediately({ + intent, + event, + onEntryPointer, + onDocumentStackSelect, + }); + + pointerIntentRef.current = intent; + handlePointerDown(event, doc.id, { - stackDocIds: stackDragDocIds, - stackSelectionApplied: false, - wasSelected: alreadySelected, + stackDocIds: intent.stackDocIdsForDrag, + stackSelectionApplied: intent.stackSelectionApplied, + wasSelected: intent.selectedAtDown, modifierActive, - stackReplace: pointerIntent.stackReplace, + stackReplace: intent.stackReplaceOnDrag, + }); + + scheduleLongPress({ + doc, + modifierActive, + pointerType, }); }} onPointerMove={(event) => { @@ -2370,66 +2491,55 @@ const DesktopWorkspaceView = () => { const dy = Number.isFinite(event.clientY) ? event.clientY - start.y : 0; if (dx * dx + dy * dy > POINTER_DRAG_THRESHOLD_SQUARED) { pointerMovedRef.current = true; + resetLongPressState(); } handlePointerMove(event); }} onPointerUp={(event) => { - const deferredInfo = deferredSelectionRef.current; const pointerState = pointerIntentRef.current; const pointerMoved = pointerMovedRef.current; + resetLongPressState(); handlePointerUp(event); - if (!pointerMoved && deferredInfo && typeof onEntryPointer === 'function') { - const entry = deferredInfo.entry || deferredInfo; - const applySelection = deferredInfo.applySelection !== false; - if (applySelection && entry) { - onEntryPointer(entry, event); + if (!pointerMoved && pointerState) { + finalizeClickSelection({ + intent: pointerState, + event, + onEntryPointer, + onDocumentStackSelect, + }); + + if ( + pointerState.clickAction === CLICK_ACTIONS.openDetail + && !pointerState.longPressTriggered + && typeof onDocumentOpen === 'function' + && pointerState.docId === doc.id + ) { + const expectedButton = + typeof pointerState.pointerButton === 'number' + ? pointerState.pointerButton + : 0; + const releasedButton = typeof event.button === 'number' + ? event.button + : expectedButton; + const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; + const stillSelected = Array.isArray(selectedDocumentIds) + && selectedDocumentIds.includes(doc.id); + if (isPrimaryRelease && stillSelected) { + const useSelection = pointerState.selectedAtDown + && pointerState.selectionCountAtDown > 0; + onDocumentOpen(doc.id, { useSelection }); + } } } - if ( - !pointerMoved - && pointerState - && pointerState.selectedAtDown - && Array.isArray(pointerState.stackClickDocIds) - && pointerState.stackClickDocIds.length > 0 - && !pointerState.stackClickApplied - && typeof onDocumentStackSelect === 'function' - ) { - onDocumentStackSelect(pointerState.stackClickDocIds, event, { replace: false }); - pointerState.stackClickApplied = true; - } - - if ( - !pointerMoved - && pointerState - && pointerState.docId === doc.id - && pointerState.openDetailOnRelease - && typeof onDocumentOpen === 'function' - ) { - const expectedButton = - typeof pointerState.pointerButton === 'number' - ? pointerState.pointerButton - : 0; - const releasedButton = typeof event.button === 'number' ? event.button : expectedButton; - const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; - const stillSelected = Array.isArray(selectedDocumentIds) - && selectedDocumentIds.includes(doc.id); - if (isPrimaryRelease && stillSelected) { - const useSelection = - pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0; - onDocumentOpen(doc.id, { useSelection }); - } - } - - deferredSelectionRef.current = null; pointerIntentRef.current = null; pointerMovedRef.current = false; }} onPointerCancel={(event) => { - deferredSelectionRef.current = null; pointerMovedRef.current = false; + resetLongPressState(); pointerIntentRef.current = null; handlePointerCancel(event); }} diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index adc706f..e8d4c09 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -13,7 +13,8 @@ import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { describeDocumentSummary } from '../documents/documentSummary'; import { createDocumentActionState } from '../documents/documentActions'; import PreviewZoomOverlay from './PreviewZoomOverlay'; -import DocumentSummarySection, { +import DocumentInfoPanel from '../documents/DocumentInfoPanel'; +import { TagSection, CorrespondentSection, sortCorrespondents, @@ -407,6 +408,94 @@ const DetailPanel = ({ return sortCorrespondents(singleDoc.correspondents || []); }, [singleDoc]); + const singleSummaryProps = useMemo( + () => ({ + tagLookupById, + tagOptions: tags, + onTagAdd: (doc, value, extras) => onTagAdd(doc, value, extras), + onTagRemove: (docId, tagId) => onTagRemove(docId, tagId), + correspondents: singleCorrespondents, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + }), + [ + tagLookupById, + tags, + onTagAdd, + onTagRemove, + singleCorrespondents, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + ], + ); + + const singleHasOcr = useMemo(() => { + if (!singleDoc || typeof getDocumentAsset !== 'function') { + return false; + } + return Boolean(getDocumentAsset(singleDoc, 'ocr-text')); + }, [singleDoc, getDocumentAsset]); + + const loadSingleOcrContent = useCallback(async ({ signal } = {}) => { + if (!singleDoc || !singleHasOcr || typeof getDocumentAsset !== 'function') { + return ''; + } + + const updateUrl = () => + resolveDocumentAssetUrl(singleDoc, 'ocr-text', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + + const asset = getDocumentAsset(singleDoc, 'ocr-text'); + let url = updateUrl(); + + if (!url && singleDoc.id && asset?.id && typeof ensureAssetUrl === 'function') { + await ensureAssetUrl(singleDoc.id, asset, { start: 1, limit: 1 }); + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + url = updateUrl(); + } + + if (!url) { + return ''; + } + + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + signal, + }); + + if (!response.ok) { + throw new Error(`Unexpected status: ${response.status}`); + } + + return response.text(); + }, [singleDoc, singleHasOcr, getDocumentAsset, ensureAssetUrl]); + + const singleContentConfig = useMemo( + () => ({ + enabled: singleHasOcr, + id: 'content', + label: 'Content', + loadContent: loadSingleOcrContent, + loadingMessage: 'Loading OCR content…', + emptyMessage: 'No OCR content available.', + unavailableMessage: 'No OCR content available.', + errorMessage: 'Failed to load OCR content.', + }), + [singleHasOcr, loadSingleOcrContent], + ); + const bulkCorrespondents = useMemo(() => { if (selectedDocuments.length <= 1) { const doc = selectedDocuments[0]; @@ -766,19 +855,17 @@ const DetailPanel = ({ ) : null} - onTagAdd(doc, value, extras)} - onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)} - correspondents={singleCorrespondents} - correspondentOptions={correspondentOptions} - onCorrespondentAdd={onCorrespondentAdd} - onCorrespondentRemove={onCorrespondentRemove} - onUpdateTitle={onUpdateTitle} - onUpdateIssued={onUpdateIssued} - /> +
+ +
); }; diff --git a/frontend/src/documents/DocumentInfoPanel.jsx b/frontend/src/documents/DocumentInfoPanel.jsx new file mode 100644 index 0000000..9b5bdc7 --- /dev/null +++ b/frontend/src/documents/DocumentInfoPanel.jsx @@ -0,0 +1,302 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import DocumentSummarySection from './DocumentSummarySection'; +import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata'; + +const DocumentInfoPanel = ({ + document, + summaryProps = {}, + metadataItems: metadataItemsProp, + metadataPayload: metadataPayloadProp, + metadataTabLabel = 'Metadata', + detailsTabLabel = 'Details', + contentConfig: contentConfigProp = null, + activeTab: controlledActiveTab, + onTabChange, + defaultTabId = 'details', + resetKey = null, + classNamePrefix = 'document-info', + hideTabNavWhenSingle = true, +}) => { + const base = classNamePrefix; + + const metadataItems = useMemo(() => { + if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) { + return metadataItemsProp; + } + return buildDocumentMetadataItems(document); + }, [metadataItemsProp, document]); + + const metadataPayload = useMemo(() => { + if (metadataPayloadProp !== undefined) { + return metadataPayloadProp; + } + return extractDocumentMetadataPayload(document); + }, [metadataPayloadProp, document]); + + const contentConfig = contentConfigProp || null; + const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true)); + const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled))); + + const [contentState, setContentState] = useState(() => { + if (!contentConfig) { + return null; + } + if (!contentEnabled || typeof contentConfig.loadContent !== 'function') { + return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null }; + } + return { status: 'idle', data: null, error: null }; + }); + + useEffect(() => { + if (!contentConfig || !showContentTab) { + setContentState(null); + return undefined; + } + + if (!contentEnabled || typeof contentConfig.loadContent !== 'function') { + setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null }); + return undefined; + } + + let cancelled = false; + const controller = new AbortController(); + + setContentState({ status: 'loading', data: null, error: null }); + + Promise.resolve(contentConfig.loadContent({ signal: controller.signal })) + .then((result) => { + if (cancelled) { + return; + } + if (result && result.length) { + setContentState({ status: 'loaded', data: result, error: null }); + } else { + setContentState({ status: 'empty', data: '', error: null }); + } + }) + .catch((error) => { + if (cancelled || error?.name === 'AbortError') { + return; + } + setContentState({ + status: 'error', + data: null, + error, + }); + }); + + return () => { + cancelled = true; + controller.abort(); + contentConfig.onCancel?.(); + }; + }, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]); + + const visibleTabs = useMemo(() => { + const tabsList = []; + + tabsList.push({ + id: 'details', + label: detailsTabLabel, + render: () => ( +
+ {metadataItems.length ? ( +
+ {metadataItems.map(({ label, value }) => ( +
+
{label}
+
{value || '—'}
+
+ ))} +
+ ) : ( +

No details available.

+ )} +
+ ), + }); + + if (showContentTab && contentConfig) { + tabsList.push({ + id: contentConfig.id || 'content', + label: contentConfig.label || 'Content', + render: () => { + const messageClass = `${base}__message`; + const errorClass = `${base}__message ${base}__message--error`; + const objectClass = `${base}__object ${base}__object--ocr-text`; + + if (!contentEnabled || !contentConfig.loadContent) { + return ( +
+ {contentConfig.unavailableMessage || 'Content not available.'} +
+ ); + } + + if (!contentState) { + return ( +
+ {contentConfig.emptyMessage || 'No content available.'} +
+ ); + } + + switch (contentState.status) { + case 'loading': + return ( +
+ {contentConfig.loadingMessage || 'Loading content…'} +
+ ); + case 'error': { + const errorMessage = + contentConfig.errorMessage + || (contentState.error instanceof Error ? contentState.error.message : null) + || 'Failed to load content.'; + return
{errorMessage}
; + } + case 'empty': + return ( +
+ {contentConfig.emptyMessage || 'No content available.'} +
+ ); + case 'loaded': + return ( +
{contentState.data}
+ ); + case 'unavailable': + return ( +
+ {contentConfig.unavailableMessage || 'Content not available.'} +
+ ); + default: + return ( +
+ {contentConfig.emptyMessage || 'No content available.'} +
+ ); + } + }, + }); + } + + if (metadataPayload) { + tabsList.push({ + id: 'metadata', + label: metadataTabLabel, + render: () => ( +
+
+              {JSON.stringify(metadataPayload, null, 2)}
+            
+
+ ), + }); + } + + return tabsList; + }, [ + base, + detailsTabLabel, + metadataItems, + contentConfig, + contentEnabled, + contentState, + metadataPayload, + metadataTabLabel, + showContentTab, + ]); + + const fallbackTabId = useMemo(() => { + if (!visibleTabs.length) { + return null; + } + if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) { + return defaultTabId; + } + return visibleTabs[0].id; + }, [visibleTabs, defaultTabId]); + + const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null; + const [uncontrolledTab, setUncontrolledTab] = useState( + isControlled ? controlledActiveTab : fallbackTabId, + ); + + useEffect(() => { + if (!isControlled) { + setUncontrolledTab(fallbackTabId); + } + }, [fallbackTabId, resetKey, isControlled]); + + useEffect(() => { + if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) { + const nextTab = fallbackTabId; + if (nextTab && nextTab !== controlledActiveTab) { + onTabChange?.(nextTab); + } + } + }, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]); + + const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab; + + const handleTabSelect = (tabId) => { + if (!visibleTabs.some((tab) => tab.id === tabId)) { + return; + } + if (!isControlled) { + setUncontrolledTab(tabId); + } + if (tabId !== activeTabId) { + onTabChange?.(tabId); + } + }; + + const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null; + const shouldHideNav = hideTabNavWhenSingle && singleTab; + + return ( + <> + + {shouldHideNav ? ( +
+
+ {renderTabContent(singleTab, { document })} +
+
+ ) : ( +
+
+ {visibleTabs.map((tab) => ( + + ))} +
+
+ {visibleTabs.map((tab) => ( + tab.id === activeTabId ? ( +
+ {renderTabContent(tab, { document })} +
+ ) : null + ))} +
+
+ )} + + ); +}; + +export default DocumentInfoPanel; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index 2d05b19..deec470 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -755,6 +755,22 @@ export const createDocumentsTableHeaderActions = ({ return ( <> + {isDeskView && typeof onShowDeskHelp === 'function' ? ( + <> + + + + ) : null}
- {isDeskView && typeof onShowDeskHelp === 'function' ? ( - - ) : null} ); }; diff --git a/frontend/src/documents/documentMetadata.js b/frontend/src/documents/documentMetadata.js new file mode 100644 index 0000000..0c57e3c --- /dev/null +++ b/frontend/src/documents/documentMetadata.js @@ -0,0 +1,49 @@ +const formatDateTime = (value) => { + if (!value) { + return '—'; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); +}; + +export const buildDocumentMetadataItems = (document) => { + if (!document) { + return []; + } + + const metadata = document.current_version || {}; + + return [ + { label: 'Created at', value: formatDateTime(document.created_at) }, + { label: 'Updated at', value: formatDateTime(document.updated_at) }, + { + label: 'Filename', + value: document.filename, + }, + { + label: 'Original filename', + value: document.original_name || '—', + }, + { + label: 'SHA-256 checksum', + value: metadata.checksum || '—', + }, + { + label: 'Content type', + value: document.content_type || '—', + }, + ]; +}; + +export const extractDocumentMetadataPayload = (document) => { + if (!document || !document.metadata) { + return null; + } + const keys = Object.keys(document.metadata); + if (!keys.length) { + return null; + } + return document.metadata; +}; + +export default buildDocumentMetadataItems; diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index 102b8f0..8f2a873 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -1,20 +1,14 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { DownloadIcon, CloseIcon } from '../ui/icons'; -import DocumentSummarySection, { +import { buildCorrespondentOptions, sortCorrespondents, } from '../documents/DocumentSummarySection'; +import DocumentInfoPanel from '../documents/DocumentInfoPanel'; +import { extractDocumentMetadataPayload } from '../documents/documentMetadata'; import { createDocumentActionState } from '../documents/documentActions'; import { resolveDocumentAssetUrl } from '../asset_manager'; -const formatDateTime = (value) => { - if (!value) { - return '—'; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); -}; - const DocumentViewerPanel = ({ document, documentId, @@ -42,32 +36,6 @@ const DocumentViewerPanel = ({ [correspondents], ); - const metadataItems = useMemo(() => { - if (!document) { - return []; - } - return [ - { label: 'Created at', value: formatDateTime(document.created_at) }, - { label: 'Updated at', value: formatDateTime(document.updated_at) }, - { - label: 'Filename', - value: document.filename, - }, - { - label: 'Original filename', - value: document.original_name || '—', - }, - { - label: 'SHA-256 checksum', - value: document.current_version?.checksum || '—', - }, - { - label: 'Content type', - value: document.content_type || '—', - }, - ]; - }, [document]); - const previewContent = useMemo(() => { if (!document || !previewEntry?.url) { return null; @@ -128,31 +96,41 @@ const DocumentViewerPanel = ({ ); }, [previewEntry, document]); - const metadataPayload = useMemo(() => { - if (!document || !document.metadata || Object.keys(document.metadata).length === 0) { - return null; - } - return document.metadata; - }, [document]); + const metadataPayload = useMemo( + () => extractDocumentMetadataPayload(document), + [document], + ); - const [activeTab, setActiveTab] = useState('details'); - useEffect(() => { - setActiveTab('details'); - }, [document?.id, hasOcr, metadataPayload]); + const summaryProps = useMemo( + () => ({ + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + correspondents: sortedCorrespondents, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + }), + [ + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + sortedCorrespondents, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + ], + ); - const [ocrContent, setOcrContent] = useState(null); - const [ocrLoading, setOcrLoading] = useState(false); - const [ocrError, setOcrError] = useState(null); - - useEffect(() => { - let cancelled = false; + const loadOcrContent = useCallback(async ({ signal } = {}) => { if (!document || !hasOcr || typeof getDocumentAsset !== 'function') { - setOcrContent(null); - setOcrLoading(false); - setOcrError(null); - return () => { - cancelled = true; - }; + return ''; } const updateUrl = () => @@ -162,68 +140,47 @@ const DocumentViewerPanel = ({ }); const asset = getDocumentAsset(document, 'ocr-text'); + let url = updateUrl(); - const ensureAndUpdate = async () => { - setOcrLoading(true); - setOcrError(null); - - let url = updateUrl(); - if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') { - try { - await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 }); - url = updateUrl(); - } catch (error) { - if (!cancelled) { - setOcrError('Unable to load OCR content.'); - } - } + if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') { + await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 }); + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); } + url = updateUrl(); + } - let textContent = null; - if (!cancelled && url) { - const controller = new AbortController(); + if (!url) { + return ''; + } - try { - const response = await fetch(url, { - method: 'GET', - mode: 'cors', - credentials: 'omit', - signal: controller.signal, - }); + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + signal, + }); - if (!response.ok) { - throw new Error(`Unexpected status: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Unexpected status: ${response.status}`); + } - textContent = await response.text(); - } catch (error) { - if (!cancelled) { - console.error('[OCR] Failed to fetch text', error); - setOcrError('Unable to load OCR content.'); - } - } + return response.text(); + }, [document, hasOcr, getDocumentAsset, ensureAssetUrl]); - if (!cancelled) { - setOcrContent(textContent); - } - - controller.abort(); - } - - if (!cancelled) { - if (!textContent) { - setOcrContent(null); - } - setOcrLoading(false); - } - }; - - ensureAndUpdate(); - - return () => { - cancelled = true; - }; - }, [document, hasOcr, ensureAssetUrl, getDocumentAsset]); + const contentTabConfig = useMemo( + () => ({ + enabled: hasOcr, + id: 'content', + label: 'Content', + loadContent: loadOcrContent, + loadingMessage: 'Loading OCR content…', + emptyMessage: 'No OCR content available.', + unavailableMessage: 'No OCR content available.', + errorMessage: 'Failed to load OCR content.', + }), + [hasOcr, loadOcrContent], + ); if (!document) { return ( @@ -243,96 +200,16 @@ const DocumentViewerPanel = ({ return (
- -
-
- - {hasOcr ? ( - - ) : null} - {metadataPayload ? ( - - ) : null} -
-
- {activeTab === 'details' ? ( -
-
-
- {metadataItems.map(({ label, value }) => ( -
-
{label}
-
{value || '—'}
-
- ))} -
-
-
- ) : null} - {activeTab === 'content' && hasOcr ? ( -
- {ocrLoading ? ( -
Loading OCR content…
- ) : ocrError ? ( -
- {ocrError} -
- ) : ocrContent ? ( -
-                    {ocrContent}
-                  
- ) : ( -
No OCR content available.
- )} -
- ) : null} - {activeTab === 'metadata' && metadataPayload ? ( -
-
-
-                    {JSON.stringify(metadataPayload, null, 2)}
-                  
-
-
- ) : null} -
-
{!previewEntry?.url ? ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 41d9e15..8d8dea3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1011,6 +1011,11 @@ button.danger:hover:not([disabled]) { display: flex; } +.document-viewer__tabpanes--single { + flex: 1; + min-height: 0; +} + .document-viewer__tabpanel { flex: 1; min-height: 0;