diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index ef37628..2e0a460 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -1,10 +1,11 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'; import DocumentSummarySection, { buildCorrespondentOptions, sortCorrespondents, } from '../documents/DocumentSummarySection'; import { createDocumentActionState } from '../documents/documentActions'; +import { resolveDocumentAssetUrl } from '../asset_manager'; const formatDateTime = (value) => { if (!value) { @@ -27,6 +28,9 @@ const DocumentViewerPanel = ({ onCorrespondentRemove, onUpdateTitle, onUpdateIssued, + hasOcr = false, + ensureAssetUrl, + getDocumentAsset, }) => { const sortedCorrespondents = useMemo( () => sortCorrespondents(document?.correspondents || []), @@ -99,6 +103,63 @@ const DocumentViewerPanel = ({ return document.metadata; }, [document]); + const [activeTab, setActiveTab] = useState('details'); + useEffect(() => { + setActiveTab('details'); + }, [document?.id, hasOcr, metadataPayload]); + + const [ocrUrl, setOcrUrl] = useState(null); + const [ocrLoading, setOcrLoading] = useState(false); + const [ocrError, setOcrError] = useState(null); + + useEffect(() => { + let cancelled = false; + if (!document || !hasOcr || typeof getDocumentAsset !== 'function') { + setOcrUrl(null); + setOcrLoading(false); + setOcrError(null); + return () => { + cancelled = true; + }; + } + + const updateUrl = () => + resolveDocumentAssetUrl(document, 'ocr-text', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + + const asset = getDocumentAsset(document, 'ocr-text'); + + 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 (!cancelled) { + setOcrUrl(url); + setOcrLoading(false); + } + }; + + ensureAndUpdate(); + + return () => { + cancelled = true; + }; + }, [document, hasOcr, ensureAssetUrl, getDocumentAsset]); + if (!document) { return (
@@ -130,23 +191,85 @@ const DocumentViewerPanel = ({ onUpdateTitle={onUpdateTitle} onUpdateIssued={onUpdateIssued} /> -
-

Metadata

-
- {metadataItems.map(({ label, value }) => ( -
-
{label}
-
{value || '—'}
+
+
+ + {hasOcr ? ( + + ) : null} + {metadataPayload ? ( + + ) : null} +
+
+ {activeTab === 'details' ? ( +
+
+
+ {metadataItems.map(({ label, value }) => ( +
+
{label}
+
{value || '—'}
+
+ ))} +
+
- ))} -
- {metadataPayload ? ( -
- Show metadata payload -
{JSON.stringify(metadataPayload, null, 2)}
-
- ) : null} -
+ ) : null} + {activeTab === 'content' && hasOcr ? ( +
+ {ocrLoading ? ( +
Loading OCR content…
+ ) : ocrError ? ( +
+ {ocrError} +
+ ) : ocrUrl ? ( + + ) : ( +
No OCR content available.
+ )} +
+ ) : null} + {activeTab === 'metadata' && metadataPayload ? ( +
+
+
+                    {JSON.stringify(metadataPayload, null, 2)}
+                  
+
+
+ ) : null} + +
{!previewEntry?.url ? ( @@ -163,26 +286,14 @@ export default DocumentViewerPanel; export const createDocumentViewerHeaderActions = ({ document, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, + actionState, onRegenerate, }) => { - if (!document) { + if (!document || !actionState) { return null; } - const { downloadHref, hasOcr, openOcr } = createDocumentActionState({ - document, - resolveApiPath, - ensurePreviewData, - ensureAssetUrl, - getDocumentAsset, - notifyApiError, - ocrErrorMessage: 'Unable to open OCR text.', - }); + const { downloadHref, hasOcr, openOcr } = actionState; return ( <> @@ -289,17 +400,25 @@ export const createDocumentViewerSurface = ({ ]; } + const actionState = document + ? createDocumentActionState({ + document, + resolveApiPath, + ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + ocrErrorMessage: 'Unable to open OCR text.', + }) + : null; + const header = { title, subtitle: null, leading, actions: createDocumentViewerHeaderActions({ document, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, + actionState, onRegenerate, }), breadcrumbs, @@ -323,6 +442,9 @@ export const createDocumentViewerSurface = ({ onCorrespondentRemove={onCorrespondentRemove} onUpdateTitle={onUpdateTitle} onUpdateIssued={onUpdateIssued} + hasOcr={Boolean(actionState?.hasOcr)} + ensureAssetUrl={ensureAssetUrl} + getDocumentAsset={getDocumentAsset} /> ), supportsDetail: false, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index cfc48ef..fc365ce 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -863,27 +863,34 @@ button.danger:hover:not([disabled]) { display: flex; flex-direction: column; gap: 0.5rem; - overflow-y: auto; + min-height: 0; + flex: 1; + overflow: hidden; +} +.document-viewer__tabs-wrapper { + display: flex; + flex-direction: column; + flex: 1; min-height: 0; } -.preview-section { - background: var(--surface-subtle); - box-shadow: inset 0 0 0 1px var(--outline-subtle); - padding: 1.25rem 1.5rem; +.document-viewer__section { display: flex; flex-direction: column; - gap: 0.75rem; + flex: 1; + min-height: 0; + overflow: auto; + padding-top: 1rem; } -.preview-section__title { +.document-viewer__section-title { margin: 0; font-size: 0.95rem; font-weight: 600; letter-spacing: 0.01em; } -.preview-section__list { +.document-viewer__section-list { margin: 0; padding: 0; list-style: none; @@ -892,51 +899,117 @@ button.danger:hover:not([disabled]) { gap: 0.75rem 1.25rem; } -.preview-section__item { +.document-viewer__section-item { display: flex; flex-direction: column; gap: 0.25rem; } -.preview-section__item dt { +.document-viewer__section-item dt { font-size: 0.75rem; color: var(--muted); } -.preview-section__item dd { +.document-viewer__section-item dd { margin: 0; font-size: 0.9rem; font-weight: 500; word-break: break-word; } -.preview-section__placeholder { +.document-viewer__section-placeholder { margin: 0; font-size: 0.9rem; color: var(--muted); } -.preview-section__payload { +.document-viewer__section-payload { font-size: 0.85rem; } -.preview-section__payload summary { +.document-viewer__section-payload summary { cursor: pointer; font-weight: 500; color: var(--accent-strong, var(--accent)); } -.preview-section__payload pre { +.document-viewer__section-payload pre { margin: 0.75rem 0 0; padding: 0.75rem; background: var(--surface); - box-shadow: inset 0 0 0 1px var(--outline-subtle); - border-radius: 6px; max-height: 280px; overflow: auto; font-size: 0.8rem; } +.document-viewer__section--metadata-json { + overflow: auto; +} + +.document-viewer__metadata-json { + margin: 0; + padding: 0.75rem; + background: var(--surface); + border-radius: 0.5rem; + font-size: 0.85rem; + line-height: 1.35; + overflow: auto; +} + +.document-viewer__tabs { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--outline-subtle); +} + +.document-viewer__tab { + appearance: none; + border: none; + background: transparent; + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--muted); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: + color 120ms ease, + border-color 120ms ease; +} + +.document-viewer__tab:hover, +.document-viewer__tab:focus-visible { + color: var(--accent); +} + +.document-viewer__tab.is-active { + color: var(--accent); + border-color: var(--accent); +} + +.document-viewer__tabpanes { + flex: 1; + min-height: 0; + display: flex; +} + +.document-viewer__tabpanel { + flex: 1; + min-height: 0; + display: flex; + position: relative; +} + +.document-viewer__object--ocr { + width: 100%; + height: 100%; +} + +.document-viewer__message--error { + color: var(--danger); +} + .document-viewer__viewport { flex: 1; min-width: 0; @@ -950,7 +1023,6 @@ button.danger:hover:not([disabled]) { width: 100%; height: 100%; border: none; - background: #fff; } .document-viewer__object--image { @@ -959,7 +1031,6 @@ button.danger:hover:not([disabled]) { max-width: 100%; max-height: 100%; object-fit: contain; - background: var(--surface); align-self: flex-start; }