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 (
-
+

{
+ 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;
}