From 30b90a5bf583d5886f5be4a4a8773fe7432f7e50 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 7 Nov 2025 10:44:07 +0100 Subject: [PATCH] detailspanel, selection handling, single document details panel --- frontend/src/app/AppLayout.jsx | 19 +- frontend/src/app/useDetailPanel.js | 161 ++-- frontend/src/detail/DetailPanel.jsx | 747 +++++-------------- frontend/src/preview/DocumentViewerPanel.jsx | 32 +- frontend/src/styles.css | 94 ++- 5 files changed, 292 insertions(+), 761 deletions(-) diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index b318601..3ce165c 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -4358,17 +4358,12 @@ const AppLayout = () => { const { detailPanelOpen, - detailPanelSelectedDocuments, + detailPanelDocument, openDetailPanel, closeDetailPanel, } = useDetailPanel({ - selectedDocumentIds, documentLookup, orderedSelectedDocuments, - selectionOrder, - documentsViewMode, - getRowId, - isDocumentRowKey, }); detailPanelControlRef.current = { @@ -4900,17 +4895,13 @@ const AppLayout = () => { const detailPanelProps = useMemo( () => ({ - selectedDocuments: detailPanelSelectedDocuments, + document: detailPanelDocument, tags, tagLookupById, onTagAdd: handleTagAdd, onTagRemove: handleTagRemove, previewEntry: selectedPreviewEntry, onOpenPreview: openDocumentPreview, - onBulkTagAdd: handleBulkTagAddFromDetail, - onBulkTagRemove: handleBulkTagRemoveFromDetail, - onBulkCorrespondentAdd: handleBulkCorrespondentAdd, - onBulkCorrespondentRemove: handleBulkCorrespondentRemove, onPromoteSelection: promoteSelectionOrder, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, @@ -4929,14 +4920,10 @@ const AppLayout = () => { [ activePreviewId, correspondents, - detailPanelSelectedDocuments, + detailPanelDocument, ensureAssetUrl, ensurePreviewData, getDocumentAsset, - handleBulkCorrespondentAdd, - handleBulkCorrespondentRemove, - handleBulkTagAddFromDetail, - handleBulkTagRemoveFromDetail, handleCorrespondentAdd, handleCorrespondentRemove, handleDetailPanelClose, diff --git a/frontend/src/app/useDetailPanel.js b/frontend/src/app/useDetailPanel.js index f2d6ec4..9df6fee 100644 --- a/frontend/src/app/useDetailPanel.js +++ b/frontend/src/app/useDetailPanel.js @@ -3,147 +3,74 @@ import { useCallback, useEffect, useRef, useState } from 'react'; export const useDetailPanel = ({ documentLookup, orderedSelectedDocuments, - selectionOrder, - documentsViewMode, - getRowId, - isDocumentRowKey, }) => { const [detailPanelOpen, setDetailPanelOpen] = useState(false); - const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); - const [detailPanelDocs, setDetailPanelDocs] = useState([]); - const lastScrolledDetailDocRef = useRef(null); + const [detailPanelDocId, setDetailPanelDocId] = useState(null); + const [detailPanelDocument, setDetailPanelDocument] = useState(null); const latestOrderedDocsRef = useRef([]); useEffect(() => { latestOrderedDocsRef.current = orderedSelectedDocuments; if (detailPanelOpen && orderedSelectedDocuments.length) { - const snapshotIds = orderedSelectedDocuments - .map((doc) => doc?.id) - .filter((id) => typeof id === 'string' || typeof id === 'number'); - if (snapshotIds.length) { - setDetailPanelDocIds(snapshotIds); + const nextDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; + if (nextDoc?.id) { + setDetailPanelDocId(nextDoc.id); + setDetailPanelDocument(nextDoc); } } }, [orderedSelectedDocuments, detailPanelOpen]); - const resolveDocsForIds = useCallback( - (ids, fallbackDocs = []) => { - if (!ids?.length) { - return []; - } - const fallbackMap = new Map((fallbackDocs || []).map((doc) => [doc?.id, doc])); - return ids - .map((id) => documentLookup.get(id) || fallbackMap.get(id) || null) - .filter(Boolean); - }, - [documentLookup], - ); - useEffect(() => { - if (!detailPanelDocIds.length) { - setDetailPanelDocs((prev) => (prev.length ? [] : prev)); - return; - } - - setDetailPanelDocs((prevDocs) => { - const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs); - if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) { - return prevDocs; + if (!detailPanelDocId) { + if (!detailPanelOpen) { + setDetailPanelDocument(null); } - return resolved; - }); - }, [detailPanelDocIds, resolveDocsForIds]); - - useEffect(() => { - if (!detailPanelOpen) { - lastScrolledDetailDocRef.current = null; return; } - - if (!orderedSelectedDocuments.length) { - lastScrolledDetailDocRef.current = null; - return; + const resolved = documentLookup.get(detailPanelDocId); + if (resolved && resolved !== detailPanelDocument) { + setDetailPanelDocument(resolved); } - - let lastSelectedId = null; - for (let index = selectionOrder.length - 1; index >= 0; index -= 1) { - const key = selectionOrder[index]; - if (isDocumentRowKey(key)) { - lastSelectedId = getRowId(key); - if (lastSelectedId) { - break; - } - } - } - - if (!lastSelectedId && orderedSelectedDocuments.length) { - const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; - lastSelectedId = fallbackDoc?.id || null; - } - - if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) { - return; - } - - if (typeof document === 'undefined') { - return; - } - - const targetElement = - document.getElementById(`document-row-${lastSelectedId}`) - || document.getElementById(`document-card-${lastSelectedId}`); - - if (!targetElement) { - return; - } - - lastScrolledDetailDocRef.current = lastSelectedId; - requestAnimationFrame(() => { - targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }); - }, [ - detailPanelOpen, - orderedSelectedDocuments, - selectionOrder, - documentsViewMode, - getRowId, - isDocumentRowKey, - ]); - - const detailPanelSelectedDocuments = detailPanelDocs; + }, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]); const openDetailPanel = useCallback( - ({ documentIds: explicitIds, documents: explicitDocs } = {}) => { - let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null; - let snapshotIds = Array.isArray(explicitIds) - ? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number') - : null; + ({ documentId, document, documentIds, documents } = {}) => { + let targetDoc = document || null; + let targetId = documentId ?? document?.id ?? null; - if (!snapshotIds?.length) { - if (!sourceDocs || !sourceDocs.length) { - sourceDocs = latestOrderedDocsRef.current; - } - snapshotIds = Array.isArray(sourceDocs) - ? sourceDocs - .map((doc) => doc?.id) - .filter((id) => typeof id === 'string' || typeof id === 'number') - : []; + if (!targetDoc && Array.isArray(documents) && documents.length) { + targetDoc = documents[documents.length - 1]; + targetId = targetDoc?.id ?? targetId; } - const uniqueIds = []; - snapshotIds.forEach((id) => { - if (!uniqueIds.includes(id)) { - uniqueIds.push(id); + if (!targetDoc && Array.isArray(documentIds) && documentIds.length) { + targetId = documentIds[documentIds.length - 1]; + } + + if (!targetDoc && targetId != null) { + targetDoc = documentLookup.get(String(targetId)) || null; + } + + if (!targetDoc) { + const fallbackDocs = latestOrderedDocsRef.current; + const fallbackDoc = Array.isArray(fallbackDocs) && fallbackDocs.length + ? fallbackDocs[fallbackDocs.length - 1] + : null; + if (fallbackDoc) { + targetDoc = fallbackDoc; + targetId = fallbackDoc.id; } - }); + } - const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current); + if (!targetDoc && targetId == null) { + return; + } - setDetailPanelDocIds(uniqueIds); - setDetailPanelDocs(resolvedDocs); - setDetailPanelOpen(true); + setDetailPanelDocId(targetDoc?.id || targetId || null); + setDetailPanelDocument(targetDoc || null); + setDetailPanelOpen(Boolean(targetDoc || targetId)); }, - [resolveDocsForIds], + [documentLookup], ); const closeDetailPanel = useCallback(() => { @@ -152,7 +79,7 @@ export const useDetailPanel = ({ return { detailPanelOpen, - detailPanelSelectedDocuments, + detailPanelDocument, openDetailPanel, closeDetailPanel, setDetailPanelOpen, diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index e8d4c09..5d4acd4 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -7,23 +7,15 @@ import { WindowMaximizeIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; -import { formatFileSize } from '../utils/format'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { describeDocumentSummary } from '../documents/documentSummary'; import { createDocumentActionState } from '../documents/documentActions'; import PreviewZoomOverlay from './PreviewZoomOverlay'; import DocumentInfoPanel from '../documents/DocumentInfoPanel'; -import { - TagSection, - CorrespondentSection, - sortCorrespondents, - buildCorrespondentOptions, -} from '../documents/DocumentSummarySection'; +import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -const MAX_PREVIEW_STACK_ITEMS = 15; - const derivePreviewOrientation = (metadata) => { const width = Number(metadata?.width); const height = Number(metadata?.height); @@ -33,122 +25,107 @@ const derivePreviewOrientation = (metadata) => { return 'landscape'; }; -const computeStackAngle = (docId, index) => { - if (index === 0) return 0; - let hash = 0; - const source = docId || `stack-${index}`; - for (let i = 0; i < source.length; i += 1) { - hash = (hash * 31 + source.charCodeAt(i)) % 997; - } - const magnitude = Math.max(3, (hash % 13) + 3); - const sign = index % 2 === 0 ? 1 : -1; - return magnitude * sign; -}; - -const PreviewStack = ({ - items = [], - maxItems = MAX_PREVIEW_STACK_ITEMS, +const PreviewImage = ({ + item, emptyMessage = 'Preview unavailable', emptyContent = null, - onItemActivate, + onActivate, onOpenPreview, onZoomPreview, + showNav = false, + canGoPrev = false, + canGoNext = false, + onGoPrev = null, + onGoNext = null, }) => { - const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]); - const hasMultiple = limited.length > 1; - const preparedItems = useMemo( - () => - limited.map((entry, index) => ({ - entry, - angle: index === 0 ? 0 : computeStackAngle(entry.id, index), - })), - [limited], - ); - - if (!limited.length) { + if (!item) { return ( -
+
{emptyContent || {emptyMessage}}
); } + const handleActivate = (event) => { + event.stopPropagation(); + if (onZoomPreview) { + onZoomPreview(item); + } else if (onOpenPreview) { + onOpenPreview(item.id); + } else if (onActivate) { + onActivate(item.id); + } + }; + + const interceptNavPointer = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + return ( -
- {preparedItems.map(({ entry, angle }, index) => { - const transform = hasMultiple - ? `translate(-50%, -50%) rotate(${angle}deg)` - : 'translate(-50%, -50%)'; - const isFront = index === 0; - return ( -
+ {item.alt} { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + handleActivate(event); + } + }} + /> + {showNav ? ( +
+
- ); - })} + + + +
+ ) : null}
); }; const DetailPanel = ({ - selectedDocuments = [], + document = null, tags = [], tagLookupById = new Map(), onTagAdd, onTagRemove, onOpenPreview, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, onPromoteSelection, onUpdateTitle = async () => false, onUpdateIssued = async () => false, @@ -163,13 +140,9 @@ const DetailPanel = ({ resolveFolderPath = null, onClose = () => {}, }) => { - const selectedCount = selectedDocuments.length; - const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; + const singleDoc = document || null; const singleDocId = singleDoc?.id || null; - const selectionKey = useMemo( - () => selectedDocuments.map((doc) => doc?.id ?? '').join('|'), - [selectedDocuments], - ); + const selectionKey = singleDocId || 'none'; const { downloadHref: singleDownloadHref } = useMemo( () => @@ -185,18 +158,10 @@ const DetailPanel = ({ const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]); - const headerTitle = useMemo(() => { - if (selectedCount === 0) { - return 'Document details'; - } - if (selectedCount === 1) { - return detailSummary.title; - } - return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; - }, [selectedCount, detailSummary]); + const headerTitle = singleDoc ? detailSummary.title : 'Document details'; const headerBreadcrumbs = useMemo(() => { - if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') { + if (!singleDoc || typeof resolveFolderPath !== 'function') { return null; } @@ -225,15 +190,10 @@ const DetailPanel = ({ label: detailSummary.title, }, ]; - }, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); + }, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); const [zoomedPreview, setZoomedPreview] = useState(null); - const bulkDocumentIds = useMemo( - () => selectedDocuments.map((doc) => doc?.id).filter(Boolean), - [selectedDocuments], - ); - useEffect(() => { setZoomedPreview(null); }, [selectionKey]); @@ -284,77 +244,22 @@ const DetailPanel = ({ [ensureAssetUrl, getDocumentAsset], ); - const stackDocuments = useMemo(() => { - if (!selectedDocuments.length) return []; - const seen = new Set(); - const ordered = []; - for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) { - const doc = selectedDocuments[index]; - if (!doc?.id || seen.has(doc.id)) continue; - seen.add(doc.id); - ordered.push(doc); - if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) { - break; - } - } - return ordered; - }, [selectedDocuments]); - - const stackTopDocument = stackDocuments[0] || null; - const stackTopDocId = stackTopDocument?.id || null; - const stackPreviewNavigator = useAssetNavigator({ - document: stackTopDocument, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); - - const singlePreviewItems = useMemo(() => { - if (!singleDoc) return []; + const singlePreviewItem = useMemo(() => { + if (!singleDoc) return null; const url = singlePreviewNavigator.currentUrl; - if (!url) { - return []; - } - const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata); - return [ - { + if (url) { + return { id: singleDoc.id, url, - orientation, + orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata), alt: singleDoc.title, - }, - ]; - }, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]); - - const stackPreviews = useMemo(() => { - if (!stackDocuments.length) { - return []; + }; } - return stackDocuments - .map((doc) => { - if (!doc) return null; - if (stackTopDocument && doc.id === stackTopDocument.id) { - const url = stackPreviewNavigator.currentUrl; - if (!url) { - return null; - } - const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata); - return { - id: doc.id, - url, - orientation, - alt: doc.title, - }; - } - return makePreviewItem(doc, 1); - }) - .filter(Boolean); + return makePreviewItem(singleDoc, 1); }, [ - stackDocuments, - stackTopDocument, - stackPreviewNavigator.currentUrl, - stackPreviewNavigator.currentMetadata, + singleDoc, + singlePreviewNavigator.currentUrl, + singlePreviewNavigator.currentMetadata, makePreviewItem, ]); @@ -362,42 +267,6 @@ const DetailPanel = ({ const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl); - const topDocId = stackTopDocument?.id || null; - const topCardinality = stackPreviewNavigator.cardinality; - const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0); - const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); - - const bulkTagUnion = useMemo(() => { - if (!selectedDocuments.length) return []; - const tagMap = new Map(); - selectedDocuments.forEach((doc) => { - (doc.tags || []).forEach((tag) => { - if (!tag?.label) return; - const label = tag.label.trim(); - if (!label) return; - if (!tagMap.has(label)) { - const fallback = tagLookupById.get(tag.id); - tagMap.set(label, { - id: tag.id, - label, - color: tag.color ?? fallback?.color ?? null, - }); - } - }); - }); - return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label)); - }, [selectedDocuments, tagLookupById]); - - const stackTotalSizeBytes = useMemo(() => { - if (!stackPreviews.length) return 0; - const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc])); - return stackPreviews.reduce((sum, item) => { - const source = byId.get(item.id); - const bytes = source?.current_version?.size_bytes; - return sum + (typeof bytes === 'number' ? bytes : 0); - }, 0); - }, [stackPreviews, selectedDocuments]); - const correspondentOptions = useMemo( () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), [correspondents], @@ -496,77 +365,9 @@ const DetailPanel = ({ [singleHasOcr, loadSingleOcrContent], ); - const bulkCorrespondents = useMemo(() => { - if (selectedDocuments.length <= 1) { - const doc = selectedDocuments[0]; - return doc ? sortCorrespondents(doc.correspondents || []) : []; - } - - const map = new Map(); - selectedDocuments.forEach((doc) => { - if (!doc?.id) return; - (doc.correspondents || []).forEach((entry) => { - if (!entry?.id || typeof entry.name !== 'string') return; - if (!map.has(entry.id)) { - map.set(entry.id, { - id: entry.id, - name: entry.name, - documentIds: new Set(), - }); - } - map.get(entry.id).documentIds.add(doc.id); - }); - }); - - return [...map.values()] - .map((entry) => ({ - id: entry.id, - name: entry.name, - documentIds: [...entry.documentIds], - count: entry.documentIds.size, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [selectedDocuments]); - - const handleBulkCorrespondentRemove = useCallback( - (entry) => { - if (!entry?.id) return; - if (onBulkCorrespondentRemove) { - return onBulkCorrespondentRemove({ - assignments: [ - { - correspondent_id: entry.id, - }, - ], - documentIds: entry.documentIds, - }); - } - - if (!onCorrespondentRemove) return; - const targets = entry.documentIds && entry.documentIds.length - ? entry.documentIds - : selectedDocuments - .filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id)) - .map((doc) => doc.id); - - return Promise.all( - targets.map((documentId) => - onCorrespondentRemove({ - documentId, - correspondentId: entry.id, - }), - ), - ).catch(() => {}); - }, - [onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], - ); - - const openZoomPreview = useCallback((config) => { - if (!config) return; - setZoomedPreview({ - mode: config.mode, - docId: config.docId ?? null, - }); + const openZoomPreview = useCallback((docId) => { + if (!docId) return; + setZoomedPreview({ docId }); }, []); const closeZoomPreview = useCallback(() => { @@ -578,64 +379,32 @@ const DetailPanel = ({ if (!singleHasPreview) return; const targetId = entry?.id ?? singleDocId; if (!targetId) return; - openZoomPreview({ mode: 'single', docId: targetId }); + openZoomPreview(targetId); }, [openZoomPreview, singleHasPreview, singleDocId], ); - const handleStackZoom = useCallback( - (entry) => { - if (!stackTopDocId || entry?.id !== stackTopDocId) return; - if (!topHasPreview) return; - openZoomPreview({ mode: 'stack', docId: stackTopDocId }); - }, - [openZoomPreview, stackTopDocId, topHasPreview], - ); - const zoomDisplay = useMemo(() => { - if (!zoomedPreview) { + if ( + !zoomedPreview + || !singleDoc + || !singleDocId + || zoomedPreview.docId !== singleDocId + || !singleHasPreview + ) { return null; } - if ( - zoomedPreview.mode === 'single' && - singleDocId && - singleDoc && - singleHasPreview && - zoomedPreview.docId === singleDocId - ) { - return { - url: singlePreviewNavigator.currentUrl, - alt: singleDoc.title, - canGoPrev: - singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), - canGoNext: - singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), - goPrev: singlePreviewNavigator.goPrev, - goNext: singlePreviewNavigator.goNext, - }; - } - - if ( - zoomedPreview.mode === 'stack' && - stackTopDocId && - stackTopDocument && - zoomedPreview.docId === stackTopDocId && - topHasPreview - ) { - return { - url: stackPreviewNavigator.currentUrl, - alt: stackTopDocument.title, - canGoPrev: - topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev), - canGoNext: - topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext), - goPrev: stackPreviewNavigator.goPrev, - goNext: stackPreviewNavigator.goNext, - }; - } - - return null; + return { + url: singlePreviewNavigator.currentUrl, + alt: singleDoc.title, + canGoPrev: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), + canGoNext: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), + goPrev: singlePreviewNavigator.goPrev, + goNext: singlePreviewNavigator.goNext, + }; }, [ zoomedPreview, singleDoc, @@ -647,15 +416,6 @@ const DetailPanel = ({ singlePreviewNavigator.goPrev, singlePreviewNavigator.goNext, singleEffectiveCardinality, - stackTopDocument, - stackTopDocId, - topHasPreview, - stackPreviewNavigator.currentUrl, - stackPreviewNavigator.canGoPrev, - stackPreviewNavigator.canGoNext, - stackPreviewNavigator.goPrev, - stackPreviewNavigator.goNext, - topEffectiveCardinality, ]); useEffect(() => { @@ -673,82 +433,41 @@ const DetailPanel = ({ cardinality: singleNavigatorCardinality, } = singlePreviewNavigator; - const { - documentId: stackNavigatorDocId, - asset: stackNavigatorAsset, - ordinal: stackNavigatorOrdinal, - canGoPrev: stackNavigatorCanGoPrev, - canGoNext: stackNavigatorCanGoNext, - cardinality: stackNavigatorCardinality, - } = stackPreviewNavigator; - useEffect(() => { if (typeof ensureAssetUrl !== 'function') { return; } + if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) { + return; + } - const warmNavigator = (navigator) => { - const { - documentId, - asset, - ordinal, - canGoPrev, - canGoNext, - cardinality, - } = navigator; - if (!documentId || !asset || !Number.isFinite(ordinal)) { - return; + const requests = []; + if (singleNavigatorCanGoPrev) { + const prevOrdinal = Math.max(1, singleNavigatorOrdinal - 1); + if (!singleNavigatorCardinality || prevOrdinal <= singleNavigatorCardinality) { + requests.push( + ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, { + start: prevOrdinal, + limit: 1, + objectOrdinal: prevOrdinal, + }), + ); } - - const requests = []; - if (canGoPrev) { - const prevOrdinal = Math.max(1, ordinal - 1); - if (!cardinality || prevOrdinal <= cardinality) { - requests.push( - ensureAssetUrl(documentId, asset, { - start: prevOrdinal, - limit: 1, - objectOrdinal: prevOrdinal, - }), - ); - } - } - if (canGoNext) { - const nextOrdinal = ordinal + 1; - if (!cardinality || nextOrdinal <= cardinality) { - requests.push( - ensureAssetUrl(documentId, asset, { - start: nextOrdinal, - limit: 1, - objectOrdinal: nextOrdinal, - }), - ); - } + } + if (singleNavigatorCanGoNext) { + const nextOrdinal = singleNavigatorOrdinal + 1; + if (!singleNavigatorCardinality || nextOrdinal <= singleNavigatorCardinality) { + requests.push( + ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, { + start: nextOrdinal, + limit: 1, + objectOrdinal: nextOrdinal, + }), + ); } + } - requests.forEach((promise) => promise?.catch?.(() => {})); - }; - - const singleWarmState = { - documentId: singleNavigatorDocId, - asset: singleNavigatorAsset, - ordinal: singleNavigatorOrdinal, - canGoPrev: singleNavigatorCanGoPrev, - canGoNext: singleNavigatorCanGoNext, - cardinality: singleNavigatorCardinality, - }; - - const stackWarmState = { - documentId: stackNavigatorDocId, - asset: stackNavigatorAsset, - ordinal: stackNavigatorOrdinal, - canGoPrev: stackNavigatorCanGoPrev, - canGoNext: stackNavigatorCanGoNext, - cardinality: stackNavigatorCardinality, - }; - - warmNavigator(singleWarmState); - warmNavigator(stackWarmState); + requests.forEach((promise) => promise?.catch?.(() => {})); }, [ ensureAssetUrl, singleNavigatorDocId, @@ -757,25 +476,18 @@ const DetailPanel = ({ singleNavigatorCanGoPrev, singleNavigatorCanGoNext, singleNavigatorCardinality, - stackNavigatorDocId, - stackNavigatorAsset, - stackNavigatorOrdinal, - stackNavigatorCanGoPrev, - stackNavigatorCanGoNext, - stackNavigatorCardinality, ]); - const renderSingle = () => { + const renderContent = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; } - const effectiveCardinality = - singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); - const canGoPrev = singlePreviewNavigator.canGoPrev; - const canGoNext = singlePreviewNavigator.canGoNext; + const effectiveCardinality = singleEffectiveCardinality; + const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev); + const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext); const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl); - const previewMissingAsset = !singleNavigatorAsset; + const previewMissingAsset = !singlePreviewNavigator.currentUrl; const displayContentType = singleDoc.content_type || 'this file type'; const displayFilename = singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download'; @@ -799,61 +511,24 @@ const DetailPanel = ({
) : null; const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…'; - const interceptNavPointer = (event) => { - event.preventDefault(); - event.stopPropagation(); - }; + const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext); return ( - <> -
- +
+ - {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( -
- - -
- ) : null}
- - ); - }; - - const renderBulk = () => { - const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; - const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; - const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`; - const topDocIdLocal = topDocId; - const topCardinalityLocal = topEffectiveCardinality; - const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); - const topCanGoPrev = stackPreviewNavigator.canGoPrev; - const topCanGoNext = stackPreviewNavigator.canGoNext; - const interceptTopNavPointer = (event) => { - event.preventDefault(); - event.stopPropagation(); - }; - const documentIds = bulkDocumentIds; - - return ( - <> -
- - {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( -
- - -
- ) : null} -
-

{headerLabel}

- onBulkTagRemove?.({ label: tag.label, documentIds })} - onAdd={({ value }) => - onBulkTagAdd?.({ label: value, input: null, documentIds }) - } - addPlaceholder="Add tag to selection" - addButtonLabel="Add tag" - datalistOptions={tags} - className="bulk-tags" - /> - - onBulkCorrespondentAdd?.({ name, input: null, documentIds }) - } - addPlaceholder="Add correspondent to selection" - datalistOptions={correspondentOptions} - showCount - className="bulk-correspondents" - /> - +
); }; @@ -1035,14 +618,16 @@ const DetailPanel = ({ actions={headerActions.length ? headerActions : null} />
- {selectedCount <= 1 ? renderSingle() : renderBulk()} + {renderContent()}
- + {singleDoc && ( + + )} ); }; diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index 8f2a873..245aee6 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -185,9 +185,11 @@ const DocumentViewerPanel = ({ if (!document) { return (
-
-
- Loading document{documentId ? ` ${documentId}` : ''}… +
+
+
+ Loading document{documentId ? ` ${documentId}` : ''}… +
@@ -199,17 +201,19 @@ const DocumentViewerPanel = ({ return (
-
- +
+
+ +
{!previewEntry?.url ? ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 033852b..5687227 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -864,7 +864,7 @@ button.danger:hover:not([disabled]) { flex: 1; display: grid; grid-template-columns: minmax(0, 30em) minmax(0, 1fr); - gap: 1.5rem; + gap: 1rem; min-height: 0; padding: 1rem 1rem; } @@ -973,7 +973,14 @@ button.danger:hover:not([disabled]) { gap: 0.5rem; min-height: 0; flex: 1; - overflow: hidden; +} + +.document-viewer__details-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + flex: 1; } .document-viewer__tabs-wrapper { display: flex; @@ -987,7 +994,6 @@ button.danger:hover:not([disabled]) { flex-direction: column; flex: 1; min-height: 0; - overflow: auto; padding-top: 1rem; } @@ -1123,7 +1129,6 @@ button.danger:hover:not([disabled]) { width: 100%; height: 100%; margin: 0; - overflow: auto; padding: 1rem 0; font-size: 1rem; white-space: pre-wrap; @@ -2882,7 +2887,7 @@ button.danger:hover:not([disabled]) { flex-direction: column; overflow-y: auto; min-height: 0; - padding: 1.25rem; + padding: 0; } .detail-section__header { @@ -3134,6 +3139,18 @@ button.danger:hover:not([disabled]) { .preview-pane { margin-top: 0.4rem; + background: transparent; + display: flex; + flex-direction: column; + gap: 1.25rem; + min-height: 0; + flex: 1; + overflow: auto; + position: relative; + padding: 1.25rem; +} + +.preview-pane__media { border-radius: 0; background: transparent; min-height: 220px; @@ -3144,6 +3161,44 @@ button.danger:hover:not([disabled]) { position: relative; } +.preview-image { + width: 100%; + max-width: 360px; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + pointer-events: auto; + position: relative; +} + +.preview-image__content { + display: block; + width: 100%; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + background: transparent; + cursor: pointer; + transition: + outline-color 120ms ease, + box-shadow 120ms ease, + filter 120ms ease, + background-color 120ms ease; + outline: 2px solid transparent; + outline-offset: -2px; +} + +.preview-image__content:hover, +.preview-image__content:focus-visible { + outline-color: var(--accent-focus); + box-shadow: + inset 0 0 0 999px var(--accent-elevated), + 0 6px 18px var(--accent-elevated-strong); +} + .thumbnail-preview { display: flex; flex-direction: column; @@ -3381,33 +3436,6 @@ button.danger:hover:not([disabled]) { height: 80%; } -.preview-stack__image { - display: block; - width: auto; - height: auto; - max-width: 100%; - max-height: 100%; - object-fit: contain; - background: transparent; - cursor: pointer; - transition: - outline-color 120ms ease, - box-shadow 120ms ease, - filter 120ms ease, - background-color 120ms ease; - outline: 2px solid transparent; - outline-offset: -2px; - pointer-events: auto; -} - -.preview-stack__image:hover, -.preview-stack__image:focus-visible { - outline-color: var(--accent-focus); - box-shadow: - inset 0 0 0 999px var(--accent-elevated), - 0 6px 18px var(--accent-elevated-strong); -} - .preview-pane__unsupported { width: 100%; max-width: 320px; @@ -3501,7 +3529,7 @@ button.danger:hover:not([disabled]) { transform: scale(calc(1 / var(--preview-nav-scale, 1))); } -.preview-pane--stack:hover .preview-pane__nav--overlay, +.preview-pane__media:hover .preview-pane__nav--overlay, .desk-item__card:hover .preview-pane__nav--overlay { opacity: 1; }