Files
papercrate/frontend/src/documents/DocumentInfoPanel.jsx
T
2025-11-05 12:47:31 +01:00

317 lines
9.5 KiB
React

import React, { useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
const DocumentInfoPanel = ({
document,
summaryProps = {},
metadataItems: metadataItemsProp,
metadataPayload: metadataPayloadProp,
metadataTabLabel = 'Metadata',
detailsTabLabel = 'Details',
contentConfig: contentConfigProp = null,
activeTab: controlledActiveTab,
onTabChange,
defaultTabId = 'details',
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
}) => {
const base = classNamePrefix;
const metadataItems = useMemo(() => {
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
return buildDocumentMetadataItems(document);
}, [metadataItemsProp, document]);
const metadataPayload = useMemo(() => {
if (metadataPayloadProp !== undefined) {
return metadataPayloadProp;
}
return extractDocumentMetadataPayload(document);
}, [metadataPayloadProp, document]);
const contentConfig = contentConfigProp || null;
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
const [contentState, setContentState] = useState(() => {
if (!contentConfig) {
return null;
}
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
}
return { status: 'idle', data: null, error: null };
});
useEffect(() => {
if (!contentConfig || !showContentTab) {
setContentState(null);
return undefined;
}
if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setContentState({ status: 'loading', data: null, error: null });
Promise.resolve(contentConfig.loadContent({ signal: controller.signal }))
.then((result) => {
if (cancelled) {
return;
}
if (result && result.length) {
setContentState({ status: 'loaded', data: result, error: null });
} else {
setContentState({ status: 'empty', data: '', error: null });
}
})
.catch((error) => {
if (cancelled || error?.name === 'AbortError') {
return;
}
setContentState({
status: 'error',
data: null,
error,
});
});
return () => {
cancelled = true;
controller.abort();
contentConfig.onCancel?.();
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
const visibleTabs = useMemo(() => {
const tabsList = [];
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
),
});
if (showContentTab && contentConfig) {
tabsList.push({
id: contentConfig.id || 'content',
label: contentConfig.label || 'Content',
render: () => {
const messageClass = `${base}__message`;
const errorClass = `${base}__message ${base}__message--error`;
const objectClass = `${base}__object ${base}__object--ocr-text`;
if (!contentEnabled || !contentConfig.loadContent) {
return (
<div className={messageClass}>
{contentConfig.unavailableMessage || 'Content not available.'}
</div>
);
}
if (!contentState) {
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
}
switch (contentState.status) {
case 'loading':
return (
<div className={messageClass}>
{contentConfig.loadingMessage || 'Loading content…'}
</div>
);
case 'error': {
const errorMessage =
contentConfig.errorMessage
|| (contentState.error instanceof Error ? contentState.error.message : null)
|| 'Failed to load content.';
return <div className={errorClass}>{errorMessage}</div>;
}
case 'empty':
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
case 'loaded':
return (
<pre className={objectClass}>{contentState.data}</pre>
);
case 'unavailable':
return (
<div className={messageClass}>
{contentConfig.unavailableMessage || 'Content not available.'}
</div>
);
default:
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
}
},
});
}
if (metadataPayload) {
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
}
return tabsList;
}, [
base,
detailsTabLabel,
metadataItems,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
]);
const fallbackTabId = useMemo(() => {
if (!visibleTabs.length) {
return null;
}
if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) {
return defaultTabId;
}
return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]);
const renderTabContent = (tab, context = {}) => {
if (!tab) {
return null;
}
if (typeof tab.render === 'function') {
return tab.render(context);
}
if (tab.component) {
const TabComponent = tab.component;
return <TabComponent {...context} />;
}
return React.isValidElement(tab.render) ? tab.render : null;
};
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
const [uncontrolledTab, setUncontrolledTab] = useState(
isControlled ? controlledActiveTab : fallbackTabId,
);
useEffect(() => {
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
}, [fallbackTabId, resetKey, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
const nextTab = fallbackTabId;
if (nextTab && nextTab !== controlledActiveTab) {
onTabChange?.(nextTab);
}
}
}, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]);
const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab;
const handleTabSelect = (tabId) => {
if (!visibleTabs.some((tab) => tab.id === tabId)) {
return;
}
if (!isControlled) {
setUncontrolledTab(tabId);
}
if (tabId !== activeTabId) {
onTabChange?.(tabId);
}
};
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
const shouldHideNav = hideTabNavWhenSingle && singleTab;
return (
<>
<DocumentSummarySection
document={document}
{...summaryProps}
/>
{shouldHideNav ? (
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
<div className={`${base}__tabpanel`}>
{renderTabContent(singleTab, { document })}
</div>
</div>
) : (
<div className={`${base}__tabs-wrapper`}>
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
</div>
)}
</>
);
};
export default DocumentInfoPanel;