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 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 (
<section className="document-viewer document-viewer--loading">
@@ -130,23 +191,85 @@ const DocumentViewerPanel = ({
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
/>
<section className="preview-section">
<h3 className="preview-section__title">Metadata</h3>
<dl className="preview-section__list">
{metadataItems.map(({ label, value }) => (
<div className="preview-section__item" key={label}>
<dt>{label}</dt>
<dd>{value || ''}</dd>
<div className="document-viewer__tabs-wrapper">
<div className="document-viewer__tabs" role="tablist" aria-label="Document details">
<button
type="button"
role="tab"
aria-selected={activeTab === 'details'}
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>
))}
</dl>
{metadataPayload ? (
<details className="preview-section__payload">
<summary>Show metadata payload</summary>
<pre>{JSON.stringify(metadataPayload, null, 2)}</pre>
</details>
) : null}
</section>
) : null}
{activeTab === 'content' && hasOcr ? (
<div role="tabpanel" className="document-viewer__tabpanel">
{ocrLoading ? (
<div className="document-viewer__message">Loading OCR content</div>
) : ocrError ? (
<div className="document-viewer__message document-viewer__message--error">
{ocrError}
</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 className="document-viewer__viewport">
{!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,
+90 -19
View File
@@ -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;
}