This commit is contained in:
2025-11-02 02:05:05 +01:00
parent 7d8595b63f
commit b9c95437d2
2 changed files with 249 additions and 56 deletions
+159 -37
View File
@@ -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 { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
import DocumentSummarySection, { import DocumentSummarySection, {
buildCorrespondentOptions, buildCorrespondentOptions,
sortCorrespondents, sortCorrespondents,
} from '../documents/DocumentSummarySection'; } from '../documents/DocumentSummarySection';
import { createDocumentActionState } from '../documents/documentActions'; import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager';
const formatDateTime = (value) => { const formatDateTime = (value) => {
if (!value) { if (!value) {
@@ -27,6 +28,9 @@ const DocumentViewerPanel = ({
onCorrespondentRemove, onCorrespondentRemove,
onUpdateTitle, onUpdateTitle,
onUpdateIssued, onUpdateIssued,
hasOcr = false,
ensureAssetUrl,
getDocumentAsset,
}) => { }) => {
const sortedCorrespondents = useMemo( const sortedCorrespondents = useMemo(
() => sortCorrespondents(document?.correspondents || []), () => sortCorrespondents(document?.correspondents || []),
@@ -99,6 +103,63 @@ const DocumentViewerPanel = ({
return document.metadata; return document.metadata;
}, [document]); }, [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) { if (!document) {
return ( return (
<section className="document-viewer document-viewer--loading"> <section className="document-viewer document-viewer--loading">
@@ -130,23 +191,85 @@ const DocumentViewerPanel = ({
onUpdateTitle={onUpdateTitle} onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued} onUpdateIssued={onUpdateIssued}
/> />
<section className="preview-section"> <div className="document-viewer__tabs-wrapper">
<h3 className="preview-section__title">Metadata</h3> <div className="document-viewer__tabs" role="tablist" aria-label="Document details">
<dl className="preview-section__list"> <button
{metadataItems.map(({ label, value }) => ( type="button"
<div className="preview-section__item" key={label}> role="tab"
<dt>{label}</dt> aria-selected={activeTab === 'details'}
<dd>{value || ''}</dd> className={`document-viewer__tab${activeTab === 'details' ? ' is-active' : ''}`}
onClick={() => setActiveTab('details')}
>
Details
</button>
{hasOcr ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'content'}
className={`document-viewer__tab${activeTab === 'content' ? ' is-active' : ''}`}
onClick={() => setActiveTab('content')}
>
Content
</button>
) : null}
{metadataPayload ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'metadata'}
className={`document-viewer__tab${activeTab === 'metadata' ? ' is-active' : ''}`}
onClick={() => setActiveTab('metadata')}
>
Metadata
</button>
) : null}
</div>
<div className="document-viewer__tabpanes">
{activeTab === 'details' ? (
<div role="tabpanel" className="document-viewer__tabpanel">
<section className="document-viewer__section">
<dl className="document-viewer__section-list">
{metadataItems.map(({ label, value }) => (
<div className="document-viewer__section-item" key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
</section>
</div> </div>
))} ) : null}
</dl> {activeTab === 'content' && hasOcr ? (
{metadataPayload ? ( <div role="tabpanel" className="document-viewer__tabpanel">
<details className="preview-section__payload"> {ocrLoading ? (
<summary>Show metadata payload</summary> <div className="document-viewer__message">Loading OCR content</div>
<pre>{JSON.stringify(metadataPayload, null, 2)}</pre> ) : ocrError ? (
</details> <div className="document-viewer__message document-viewer__message--error">
) : null} {ocrError}
</section> </div>
) : ocrUrl ? (
<embed
src={ocrUrl}
type="text/plain"
className="document-viewer__object document-viewer__object--ocr"
/>
) : (
<div className="document-viewer__message">No OCR content available.</div>
)}
</div>
) : null}
{activeTab === 'metadata' && metadataPayload ? (
<div role="tabpanel" className="document-viewer__tabpanel">
<section className="document-viewer__section document-viewer__section--metadata-json">
<pre className="document-viewer__metadata-json">
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
</div>
) : null}
</div>
</div>
</div> </div>
<div className="document-viewer__viewport"> <div className="document-viewer__viewport">
{!previewEntry?.url ? ( {!previewEntry?.url ? (
@@ -163,26 +286,14 @@ export default DocumentViewerPanel;
export const createDocumentViewerHeaderActions = ({ export const createDocumentViewerHeaderActions = ({
document, document,
ensureAssetUrl, actionState,
ensurePreviewData,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate, onRegenerate,
}) => { }) => {
if (!document) { if (!document || !actionState) {
return null; return null;
} }
const { downloadHref, hasOcr, openOcr } = createDocumentActionState({ const { downloadHref, hasOcr, openOcr } = actionState;
document,
resolveApiPath,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
ocrErrorMessage: 'Unable to open OCR text.',
});
return ( 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 = { const header = {
title, title,
subtitle: null, subtitle: null,
leading, leading,
actions: createDocumentViewerHeaderActions({ actions: createDocumentViewerHeaderActions({
document, document,
ensureAssetUrl, actionState,
ensurePreviewData,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate, onRegenerate,
}), }),
breadcrumbs, breadcrumbs,
@@ -323,6 +442,9 @@ export const createDocumentViewerSurface = ({
onCorrespondentRemove={onCorrespondentRemove} onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle} onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued} onUpdateIssued={onUpdateIssued}
hasOcr={Boolean(actionState?.hasOcr)}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
/> />
), ),
supportsDetail: false, supportsDetail: false,
+90 -19
View File
@@ -863,27 +863,34 @@ button.danger:hover:not([disabled]) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; 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; min-height: 0;
} }
.preview-section { .document-viewer__section {
background: var(--surface-subtle);
box-shadow: inset 0 0 0 1px var(--outline-subtle);
padding: 1.25rem 1.5rem;
display: flex; display: flex;
flex-direction: column; 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; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.preview-section__list { .document-viewer__section-list {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
@@ -892,51 +899,117 @@ button.danger:hover:not([disabled]) {
gap: 0.75rem 1.25rem; gap: 0.75rem 1.25rem;
} }
.preview-section__item { .document-viewer__section-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.preview-section__item dt { .document-viewer__section-item dt {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--muted); color: var(--muted);
} }
.preview-section__item dd { .document-viewer__section-item dd {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
word-break: break-word; word-break: break-word;
} }
.preview-section__placeholder { .document-viewer__section-placeholder {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
} }
.preview-section__payload { .document-viewer__section-payload {
font-size: 0.85rem; font-size: 0.85rem;
} }
.preview-section__payload summary { .document-viewer__section-payload summary {
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
color: var(--accent-strong, var(--accent)); color: var(--accent-strong, var(--accent));
} }
.preview-section__payload pre { .document-viewer__section-payload pre {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
padding: 0.75rem; padding: 0.75rem;
background: var(--surface); background: var(--surface);
box-shadow: inset 0 0 0 1px var(--outline-subtle);
border-radius: 6px;
max-height: 280px; max-height: 280px;
overflow: auto; overflow: auto;
font-size: 0.8rem; 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 { .document-viewer__viewport {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -950,7 +1023,6 @@ button.danger:hover:not([disabled]) {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: none; border: none;
background: #fff;
} }
.document-viewer__object--image { .document-viewer__object--image {
@@ -959,7 +1031,6 @@ button.danger:hover:not([disabled]) {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
background: var(--surface);
align-self: flex-start; align-self: flex-start;
} }