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 { 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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user