diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx
index 5d4acd4..ea3752a 100644
--- a/frontend/src/detail/DetailPanel.jsx
+++ b/frontend/src/detail/DetailPanel.jsx
@@ -1,123 +1,17 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
import {
DownloadIcon,
- ArrowLeftIcon,
- ArrowRightIcon,
DetailPanelCollapseIcon,
WindowMaximizeIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
-import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
-import { useAssetNavigator } from '../hooks/useAssetNavigator';
+import { resolveDocumentAssetUrl } from '../asset_manager';
import { describeDocumentSummary } from '../documents/documentSummary';
import { createDocumentActionState } from '../documents/documentActions';
-import PreviewZoomOverlay from './PreviewZoomOverlay';
-import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
-
-const derivePreviewOrientation = (metadata) => {
- const width = Number(metadata?.width);
- const height = Number(metadata?.height);
- if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
- return width >= height ? 'landscape' : 'portrait';
- }
- return 'landscape';
-};
-
-const PreviewImage = ({
- item,
- emptyMessage = 'Preview unavailable',
- emptyContent = null,
- onActivate,
- onOpenPreview,
- onZoomPreview,
- showNav = false,
- canGoPrev = false,
- canGoNext = false,
- onGoPrev = null,
- onGoNext = null,
-}) => {
- 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 (
-
-

{
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- event.stopPropagation();
- handleActivate(event);
- }
- }}
- />
- {showNav ? (
-
-
-
-
- ) : null}
-
- );
-};
+import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
+import DocumentViewerLayout from '../preview/DocumentViewerLayout';
const DetailPanel = ({
document = null,
@@ -126,7 +20,6 @@ const DetailPanel = ({
onTagAdd,
onTagRemove,
onOpenPreview,
- onPromoteSelection,
onUpdateTitle = async () => false,
onUpdateIssued = async () => false,
ensureAssetUrl = null,
@@ -138,11 +31,10 @@ const DetailPanel = ({
resolveApiPath,
onFolderNavigate = null,
resolveFolderPath = null,
+ previewEntry = null,
onClose = () => {},
}) => {
const singleDoc = document || null;
- const singleDocId = singleDoc?.id || null;
- const selectionKey = singleDocId || 'none';
const { downloadHref: singleDownloadHref } = useMemo(
() =>
@@ -192,81 +84,6 @@ const DetailPanel = ({
];
}, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
- const [zoomedPreview, setZoomedPreview] = useState(null);
-
- useEffect(() => {
- setZoomedPreview(null);
- }, [selectionKey]);
-
- const handlePreviewActivate = useCallback(
- (docId) => {
- if (!docId) return;
- onPromoteSelection?.(docId);
- },
- [onPromoteSelection],
- );
-
- const singlePreviewNavigator = useAssetNavigator({
- document: singleDoc,
- assetType: 'preview',
- ensureAssetUrl,
- getAsset: getDocumentAsset,
- prefetch: 3,
- });
-
- const makePreviewItem = useCallback(
- (doc, ordinal = 1) => {
- if (!doc) return null;
- const asset = getDocumentAsset(doc, 'preview');
- const assetView = createAssetView(asset);
- const object = assetView.getObject(ordinal);
- let url = object?.url || null;
- if (!url) {
- url = resolveDocumentAssetUrl(doc, 'preview', {
- ensureAssetUrl,
- getAsset: getDocumentAsset,
- ensureOptions: { start: ordinal, limit: 1 },
- objectOrdinal: ordinal,
- });
- }
- if (!url) {
- return null;
- }
- const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
- const orientation = derivePreviewOrientation(metadata);
- return {
- id: doc.id,
- url,
- orientation,
- alt: doc.title,
- };
- },
- [ensureAssetUrl, getDocumentAsset],
- );
-
- const singlePreviewItem = useMemo(() => {
- if (!singleDoc) return null;
- const url = singlePreviewNavigator.currentUrl;
- if (url) {
- return {
- id: singleDoc.id,
- url,
- orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata),
- alt: singleDoc.title,
- };
- }
- return makePreviewItem(singleDoc, 1);
- }, [
- singleDoc,
- singlePreviewNavigator.currentUrl,
- singlePreviewNavigator.currentMetadata,
- makePreviewItem,
- ]);
-
- const singleCardinality = singlePreviewNavigator.cardinality;
- const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
- const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
-
const correspondentOptions = useMemo(
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
[correspondents],
@@ -277,6 +94,12 @@ const DetailPanel = ({
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
+ useEffect(() => {
+ if (typeof ensurePreviewData === 'function' && singleDoc?.id) {
+ ensurePreviewData(singleDoc.id);
+ }
+ }, [ensurePreviewData, singleDoc?.id]);
+
const singleSummaryProps = useMemo(
() => ({
tagLookupById,
@@ -304,6 +127,11 @@ const DetailPanel = ({
],
);
+ const singleMetadataPayload = useMemo(
+ () => extractDocumentMetadataPayload(singleDoc),
+ [singleDoc],
+ );
+
const singleHasOcr = useMemo(() => {
if (!singleDoc || typeof getDocumentAsset !== 'function') {
return false;
@@ -365,183 +193,22 @@ const DetailPanel = ({
[singleHasOcr, loadSingleOcrContent],
);
- const openZoomPreview = useCallback((docId) => {
- if (!docId) return;
- setZoomedPreview({ docId });
- }, []);
-
- const closeZoomPreview = useCallback(() => {
- setZoomedPreview(null);
- }, []);
-
- const handleSingleZoom = useCallback(
- (entry) => {
- if (!singleHasPreview) return;
- const targetId = entry?.id ?? singleDocId;
- if (!targetId) return;
- openZoomPreview(targetId);
- },
- [openZoomPreview, singleHasPreview, singleDocId],
- );
-
- const zoomDisplay = useMemo(() => {
- if (
- !zoomedPreview
- || !singleDoc
- || !singleDocId
- || zoomedPreview.docId !== singleDocId
- || !singleHasPreview
- ) {
- 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,
- singleDocId,
- singleHasPreview,
- singlePreviewNavigator.currentUrl,
- singlePreviewNavigator.canGoPrev,
- singlePreviewNavigator.canGoNext,
- singlePreviewNavigator.goPrev,
- singlePreviewNavigator.goNext,
- singleEffectiveCardinality,
- ]);
-
- useEffect(() => {
- if (zoomedPreview && !zoomDisplay) {
- setZoomedPreview(null);
- }
- }, [zoomedPreview, zoomDisplay]);
-
- const {
- documentId: singleNavigatorDocId,
- asset: singleNavigatorAsset,
- ordinal: singleNavigatorOrdinal,
- canGoPrev: singleNavigatorCanGoPrev,
- canGoNext: singleNavigatorCanGoNext,
- cardinality: singleNavigatorCardinality,
- } = singlePreviewNavigator;
-
- useEffect(() => {
- if (typeof ensureAssetUrl !== 'function') {
- return;
- }
- if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) {
- 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,
- }),
- );
- }
- }
- 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?.(() => {}));
- }, [
- ensureAssetUrl,
- singleNavigatorDocId,
- singleNavigatorAsset,
- singleNavigatorOrdinal,
- singleNavigatorCanGoPrev,
- singleNavigatorCanGoNext,
- singleNavigatorCardinality,
- ]);
-
const renderContent = () => {
if (!singleDoc) {
return Select a document to view metadata, tags and actions.
;
}
- const effectiveCardinality = singleEffectiveCardinality;
- const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev);
- const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext);
- const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
- const previewMissingAsset = !singlePreviewNavigator.currentUrl;
- const displayContentType = singleDoc.content_type || 'this file type';
- const displayFilename =
- singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
- const previewFallback = previewMissingAsset ? (
-
-
- Preview not available for {displayContentType} files.
-
-
{displayFilename}
- {singleDownloadHref ? (
-
-
- Download
-
- ) : null}
-
- ) : null;
- const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
- const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext);
-
return (
-
+
);
};
@@ -598,37 +265,26 @@ const DetailPanel = ({
}
return (
- <>
-
- {singleDoc && (
-
- )}
- >
+
);
};
diff --git a/frontend/src/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.js
index 4c74455..27859c6 100644
--- a/frontend/src/detail/useDetailWorkspace.js
+++ b/frontend/src/detail/useDetailWorkspace.js
@@ -22,7 +22,6 @@ const useDetailWorkspace = ({
previewDocumentId,
activePreviewId,
openDocumentPreview,
- promoteSelectionOrder,
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleDocumentTagAdd,
@@ -242,7 +241,6 @@ const useDetailWorkspace = ({
onTagRemove: handleTagRemove,
previewEntry: selectedPreviewEntry,
onOpenPreview: openDocumentPreview,
- onPromoteSelection: promoteSelectionOrder,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssued: handleDocumentIssuedUpdate,
@@ -272,7 +270,6 @@ const useDetailWorkspace = ({
handleDocumentTitleUpdate,
handleTagRemove,
openDocumentPreview,
- promoteSelectionOrder,
resolveApiPath,
resolveFolderPath,
selectFolder,
diff --git a/frontend/src/preview/DocumentViewerLayout.jsx b/frontend/src/preview/DocumentViewerLayout.jsx
new file mode 100644
index 0000000..964e5ea
--- /dev/null
+++ b/frontend/src/preview/DocumentViewerLayout.jsx
@@ -0,0 +1,105 @@
+import React, { useMemo } from 'react';
+import DocumentInfoPanel from '../documents/DocumentInfoPanel';
+import { DownloadIcon } from '../ui/icons';
+
+const DocumentViewerLayout = ({
+ document,
+ previewEntry,
+ summaryProps,
+ metadataPayload,
+ contentTabConfig,
+ resetKey,
+ classNamePrefix = 'document-viewer',
+ defaultTabId = 'details',
+ infoPanelProps = {},
+ previewLoadingMessage = 'Preparing preview…',
+}) => {
+ const previewContent = useMemo(() => {
+ if (!document || !previewEntry?.url) {
+ return null;
+ }
+
+ const normalizedContentType = (previewEntry.contentType
+ || document.content_type
+ || '')
+ .toLowerCase();
+ const isImage = normalizedContentType.startsWith('image/');
+ const isPdf = normalizedContentType === 'application/pdf'
+ || normalizedContentType === 'application/x-pdf';
+
+ if (isImage) {
+ return (
+
+ );
+ }
+
+ if (isPdf) {
+ return (
+
+ );
+ }
+
+ const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
+ const displayFilename = previewEntry.filename
+ || document.filename
+ || document.original_name
+ || 'download';
+
+ return (
+
+
+ Preview is not available for {displayContentType} files.
+
+
{displayFilename}
+
+
+ Download
+
+
+ );
+ }, [previewEntry, document]);
+
+ return (
+ <>
+
+
+ {!previewEntry?.url ? (
+
{previewLoadingMessage}
+ ) : (
+ previewContent
+ )}
+
+ >
+ );
+};
+
+export default DocumentViewerLayout;
diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx
index 2c8cf73..cc41e41 100644
--- a/frontend/src/preview/DocumentViewerPanel.jsx
+++ b/frontend/src/preview/DocumentViewerPanel.jsx
@@ -11,10 +11,10 @@ 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';
+import DocumentViewerLayout from './DocumentViewerLayout';
const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio)
const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.6;
@@ -83,66 +83,6 @@ const DocumentViewerPanel = ({
[correspondents],
);
- const previewContent = useMemo(() => {
- if (!document || !previewEntry?.url) {
- return null;
- }
-
- const normalizedContentType = (previewEntry.contentType
- || document.content_type
- || '')
- .toLowerCase();
- const isImage = normalizedContentType.startsWith('image/');
- const isPdf = normalizedContentType === 'application/pdf'
- || normalizedContentType === 'application/x-pdf';
-
- if (isImage) {
- return (
-
- );
- }
-
- if (isPdf) {
- return (
-
- );
- }
-
- const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
- const displayFilename = previewEntry.filename
- || document.filename
- || document.original_name
- || 'download';
-
- return (
-
-
- Preview is not available for {displayContentType} files.
-
-
{displayFilename}
-
-
- Download
-
-
- );
- }, [previewEntry, document]);
-
const metadataPayload = useMemo(
() => extractDocumentMetadataPayload(document),
[document],
@@ -316,27 +256,14 @@ const DocumentViewerPanel = ({
return (
-
-
- {!previewEntry?.url ? (
-
Loading preview…
- ) : (
- previewContent
- )}
-
+
);
};