details
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user