document viewer
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import DocumentSummarySection from './DocumentSummarySection';
|
import DocumentSummarySection from './DocumentSummarySection';
|
||||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||||
|
|
||||||
@@ -16,6 +16,13 @@ const DocumentInfoPanel = ({
|
|||||||
resetKey = null,
|
resetKey = null,
|
||||||
classNamePrefix = 'document-info',
|
classNamePrefix = 'document-info',
|
||||||
hideTabNavWhenSingle = true,
|
hideTabNavWhenSingle = true,
|
||||||
|
summaryPlacement = 'inline',
|
||||||
|
summaryTabLabel = 'Summary',
|
||||||
|
summaryTabId = 'summary',
|
||||||
|
leadingTabs = [],
|
||||||
|
trailingTabs = [],
|
||||||
|
tabsPlacement = 'top',
|
||||||
|
summaryLayout = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
const base = classNamePrefix;
|
const base = classNamePrefix;
|
||||||
|
|
||||||
@@ -92,29 +99,90 @@ const DocumentInfoPanel = ({
|
|||||||
};
|
};
|
||||||
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
|
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
|
||||||
|
|
||||||
|
const renderSummarySection = useCallback(() => (
|
||||||
|
<DocumentSummarySection
|
||||||
|
document={document}
|
||||||
|
detailItems={metadataItems}
|
||||||
|
layout={summaryLayout}
|
||||||
|
{...summaryProps}
|
||||||
|
/>
|
||||||
|
), [document, summaryLayout, summaryProps, metadataItems]);
|
||||||
|
|
||||||
|
const renderDetailsSection = useCallback(() => (
|
||||||
|
<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>
|
||||||
|
), [base, metadataItems]);
|
||||||
|
|
||||||
|
const summaryInline = summaryPlacement !== 'tabs';
|
||||||
|
|
||||||
|
const summaryTab = useMemo(() => {
|
||||||
|
if (summaryPlacement !== 'tabs') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: summaryTabId,
|
||||||
|
label: summaryTabLabel,
|
||||||
|
render: () => (
|
||||||
|
<div className={`${base}__summary-tab-content`}>
|
||||||
|
{renderSummarySection()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]);
|
||||||
|
|
||||||
|
const normalizedLeadingTabs = useMemo(
|
||||||
|
() => (Array.isArray(leadingTabs)
|
||||||
|
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
|
||||||
|
: []),
|
||||||
|
[leadingTabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedTrailingTabs = useMemo(
|
||||||
|
() => (Array.isArray(trailingTabs)
|
||||||
|
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
|
||||||
|
: []),
|
||||||
|
[trailingTabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryNode = summaryInline
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{renderSummarySection()}
|
||||||
|
{renderDetailsSection()}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const visibleTabs = useMemo(() => {
|
const visibleTabs = useMemo(() => {
|
||||||
const tabsList = [];
|
const tabsList = [];
|
||||||
|
|
||||||
tabsList.push({
|
if (normalizedLeadingTabs.length) {
|
||||||
id: 'details',
|
tabsList.push(...normalizedLeadingTabs);
|
||||||
label: detailsTabLabel,
|
}
|
||||||
render: () => (
|
|
||||||
<section className={`${base}__section`}>
|
if (summaryTab) {
|
||||||
{metadataItems.length ? (
|
tabsList.push(summaryTab);
|
||||||
<dl className={`${base}__section-list`}>
|
}
|
||||||
{metadataItems.map(({ label, value }) => (
|
|
||||||
<div className={`${base}__section-item`} key={label}>
|
if (summaryPlacement !== 'tabs') {
|
||||||
<dt>{label}</dt>
|
tabsList.push({
|
||||||
<dd>{value || '—'}</dd>
|
id: 'details',
|
||||||
</div>
|
label: detailsTabLabel,
|
||||||
))}
|
render: () => renderDetailsSection(),
|
||||||
</dl>
|
});
|
||||||
) : (
|
}
|
||||||
<p className={`${base}__section-placeholder`}>No details available.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (showContentTab && contentConfig) {
|
if (showContentTab && contentConfig) {
|
||||||
tabsList.push({
|
tabsList.push({
|
||||||
@@ -183,30 +251,38 @@ const DocumentInfoPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (metadataPayload) {
|
if (metadataPayload) {
|
||||||
tabsList.push({
|
tabsList.push({
|
||||||
id: 'metadata',
|
id: 'metadata',
|
||||||
label: metadataTabLabel,
|
label: metadataTabLabel,
|
||||||
render: () => (
|
render: () => (
|
||||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||||
<pre className={`${base}__metadata-json`}>
|
<pre className={`${base}__metadata-json`}>
|
||||||
{JSON.stringify(metadataPayload, null, 2)}
|
{JSON.stringify(metadataPayload, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedTrailingTabs.length) {
|
||||||
|
tabsList.push(...normalizedTrailingTabs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabsList;
|
return tabsList;
|
||||||
}, [
|
}, [
|
||||||
base,
|
base,
|
||||||
detailsTabLabel,
|
detailsTabLabel,
|
||||||
metadataItems,
|
|
||||||
contentConfig,
|
contentConfig,
|
||||||
contentEnabled,
|
contentEnabled,
|
||||||
contentState,
|
contentState,
|
||||||
metadataPayload,
|
metadataPayload,
|
||||||
metadataTabLabel,
|
metadataTabLabel,
|
||||||
showContentTab,
|
showContentTab,
|
||||||
|
summaryTab,
|
||||||
|
normalizedLeadingTabs,
|
||||||
|
normalizedTrailingTabs,
|
||||||
|
summaryPlacement,
|
||||||
|
renderDetailsSection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fallbackTabId = useMemo(() => {
|
const fallbackTabId = useMemo(() => {
|
||||||
@@ -242,7 +318,7 @@ const DocumentInfoPanel = ({
|
|||||||
if (!isControlled) {
|
if (!isControlled) {
|
||||||
setUncontrolledTab(fallbackTabId);
|
setUncontrolledTab(fallbackTabId);
|
||||||
}
|
}
|
||||||
}, [fallbackTabId, resetKey, isControlled]);
|
}, [fallbackTabId, isControlled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
|
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
|
||||||
@@ -270,12 +346,40 @@ const DocumentInfoPanel = ({
|
|||||||
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
|
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
|
||||||
const shouldHideNav = hideTabNavWhenSingle && singleTab;
|
const shouldHideNav = hideTabNavWhenSingle && singleTab;
|
||||||
|
|
||||||
|
const tabNav = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabPanels = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentSummarySection
|
{summaryNode}
|
||||||
document={document}
|
|
||||||
{...summaryProps}
|
|
||||||
/>
|
|
||||||
{shouldHideNav ? (
|
{shouldHideNav ? (
|
||||||
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
|
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
|
||||||
<div className={`${base}__tabpanel`}>
|
<div className={`${base}__tabpanel`}>
|
||||||
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`${base}__tabs-wrapper`}>
|
<div className={tabsWrapperClass}>
|
||||||
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
|
{tabsPlacement !== 'bottom' ? tabNav : null}
|
||||||
{visibleTabs.map((tab) => (
|
{tabsPlacement === 'bottom' ? tabPanels : null}
|
||||||
<button
|
{tabsPlacement === 'bottom' ? tabNav : null}
|
||||||
key={tab.id}
|
{tabsPlacement !== 'bottom' ? tabPanels : null}
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -348,8 +348,11 @@ const DocumentSummarySection = ({
|
|||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
onUpdateIssued
|
onUpdateIssued,
|
||||||
|
layout = 'default',
|
||||||
|
detailItems = [],
|
||||||
}) => {
|
}) => {
|
||||||
|
const isCompactLayout = layout === 'compact';
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return {
|
return {
|
||||||
@@ -507,6 +510,204 @@ const DocumentSummarySection = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TitleSection = () => (
|
||||||
|
editableTitle && isTitleEditing ? (
|
||||||
|
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||||
|
<input
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitleDraft(event.target.value);
|
||||||
|
if (titleError) {
|
||||||
|
setTitleError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelTitleEdit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Document title"
|
||||||
|
autoFocus
|
||||||
|
disabled={titleSaving}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={titleSaving}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelTitleEdit}
|
||||||
|
disabled={titleSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="doc-title-row__title">{summary.title}</h3>
|
||||||
|
{editableTitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={startTitleEdit}
|
||||||
|
aria-label="Edit title"
|
||||||
|
title="Edit title"
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const issuedDisplay = editableIssued && isIssuedEditing ? (
|
||||||
|
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={issuedDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIssuedDraft(event.target.value);
|
||||||
|
if (issuedError) {
|
||||||
|
setIssuedError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Issued on"
|
||||||
|
disabled={issuedSaving}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={issuedSaving}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelIssuedEdit}
|
||||||
|
disabled={issuedSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
|
||||||
|
{editableIssued ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={startIssuedEdit}
|
||||||
|
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||||
|
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const metaItems = [
|
||||||
|
{
|
||||||
|
key: 'issued',
|
||||||
|
label: 'Issued',
|
||||||
|
valueContent: issuedDisplay,
|
||||||
|
error: issuedError,
|
||||||
|
},
|
||||||
|
...metaRows.map((row) => ({
|
||||||
|
key: row.key,
|
||||||
|
label: row.label,
|
||||||
|
fallbackValue: row.value,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const detailRows = Array.isArray(detailItems)
|
||||||
|
? detailItems.map((item, index) => ({
|
||||||
|
key: `detail-${item?.label || index}`,
|
||||||
|
label: item?.label || '—',
|
||||||
|
fallbackValue: item?.value,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const compactRows = [...metaItems, ...detailRows];
|
||||||
|
|
||||||
|
const renderTags = () => (
|
||||||
|
<section className="document-summary__section document-summary__section--tags">
|
||||||
|
<TagSection
|
||||||
|
tags={resolvedTags}
|
||||||
|
onRemove={
|
||||||
|
onTagRemove
|
||||||
|
? (tag) => onTagRemove(document.id, tag.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onAdd={
|
||||||
|
onTagAdd
|
||||||
|
? ({ value, option }) => onTagAdd(document, value, { option })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
datalistOptions={tagOptions}
|
||||||
|
className="document-summary__tags"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCorrespondents = () => (
|
||||||
|
<section className="document-summary__section document-summary__section--correspondents">
|
||||||
|
<CorrespondentSection
|
||||||
|
entries={resolvedCorrespondents}
|
||||||
|
onRemove={
|
||||||
|
onCorrespondentRemove
|
||||||
|
? (entry) =>
|
||||||
|
onCorrespondentRemove({
|
||||||
|
documentId: document.id,
|
||||||
|
correspondentId: entry.id,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onAdd={
|
||||||
|
onCorrespondentAdd
|
||||||
|
? ({ name, option }) =>
|
||||||
|
onCorrespondentAdd({
|
||||||
|
document,
|
||||||
|
name,
|
||||||
|
option,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
showCount
|
||||||
|
datalistOptions={correspondentOptions}
|
||||||
|
className="document-summary__correspondents"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCompactLayout) {
|
||||||
|
return (
|
||||||
|
<div className="document-summary document-summary--compact">
|
||||||
|
<section className="document-summary__section document-summary__title-row">
|
||||||
|
<TitleSection />
|
||||||
|
</section>
|
||||||
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||||
|
{renderTags()}
|
||||||
|
{renderCorrespondents()}
|
||||||
|
{compactRows.length ? (
|
||||||
|
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
|
||||||
|
<dl className="document-summary__details-list document-summary__details-list--meta">
|
||||||
|
{compactRows.map((item) => (
|
||||||
|
<div key={item.key} className="document-summary__details-row">
|
||||||
|
<dt>{item.label}</dt>
|
||||||
|
<dd>
|
||||||
|
{item.valueContent != null && item.valueContent !== ''
|
||||||
|
? item.valueContent
|
||||||
|
: item.fallbackValue || '—'}
|
||||||
|
</dd>
|
||||||
|
{item.error ? <div className="status-inline error">{item.error}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="document-summary">
|
<div className="document-summary">
|
||||||
<div className="doc-title-row">
|
<div className="doc-title-row">
|
||||||
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({
|
|||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={startTitleEdit}
|
onClick={startTitleEdit}
|
||||||
aria-label="Edit title"
|
aria-label="Edit title"
|
||||||
title="Edit title"
|
title="Edit title"
|
||||||
>
|
>
|
||||||
<EditIcon className="icon-inline" />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||||
|
|
||||||
<div className="detail-meta">
|
<div className="detail-meta">
|
||||||
<div className="detail-meta__row">
|
<div className="detail-meta__row">
|
||||||
<span className="detail-meta__label">Issued:</span>
|
<span className="detail-meta__label">Issued:</span>
|
||||||
{editableIssued && isIssuedEditing ? (
|
{issuedDisplay}
|
||||||
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={issuedDraft}
|
|
||||||
onChange={(event) => {
|
|
||||||
setIssuedDraft(event.target.value);
|
|
||||||
if (issuedError) {
|
|
||||||
setIssuedError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Issued on"
|
|
||||||
disabled={issuedSaving}
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={issuedSaving}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={cancelIssuedEdit}
|
|
||||||
disabled={issuedSaving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
|
|
||||||
{editableIssued ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={startIssuedEdit}
|
|
||||||
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
|
||||||
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
|
||||||
>
|
|
||||||
<EditIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
||||||
|
|
||||||
@@ -619,45 +779,9 @@ const DocumentSummarySection = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TagSection
|
{renderTags()}
|
||||||
tags={resolvedTags}
|
|
||||||
onRemove={
|
|
||||||
onTagRemove
|
|
||||||
? (tag) => onTagRemove(document.id, tag.id)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onAdd={
|
|
||||||
onTagAdd
|
|
||||||
? ({ value, option }) => onTagAdd(document, value, { option })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
datalistOptions={tagOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CorrespondentSection
|
{renderCorrespondents()}
|
||||||
entries={resolvedCorrespondents}
|
|
||||||
onRemove={
|
|
||||||
onCorrespondentRemove
|
|
||||||
? (entry) =>
|
|
||||||
onCorrespondentRemove({
|
|
||||||
documentId: document.id,
|
|
||||||
correspondentId: entry.id,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onAdd={
|
|
||||||
onCorrespondentAdd
|
|
||||||
? ({ name, option }) =>
|
|
||||||
onCorrespondentAdd({
|
|
||||||
document,
|
|
||||||
name,
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
showCount
|
|
||||||
datalistOptions={correspondentOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||||
import { DownloadIcon } from '../ui/icons';
|
import { DownloadIcon } from '../ui/icons';
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ const DocumentViewerLayout = ({
|
|||||||
defaultTabId = 'details',
|
defaultTabId = 'details',
|
||||||
infoPanelProps = {},
|
infoPanelProps = {},
|
||||||
previewLoadingMessage = 'Preparing preview…',
|
previewLoadingMessage = 'Preparing preview…',
|
||||||
|
layoutMode = 'split',
|
||||||
}) => {
|
}) => {
|
||||||
|
const isStacked = layoutMode === 'stacked';
|
||||||
|
|
||||||
const previewContent = useMemo(() => {
|
const previewContent = useMemo(() => {
|
||||||
if (!document || !previewEntry?.url) {
|
if (!document || !previewEntry?.url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -74,30 +77,65 @@ const DocumentViewerLayout = ({
|
|||||||
);
|
);
|
||||||
}, [previewEntry, document]);
|
}, [previewEntry, document]);
|
||||||
|
|
||||||
|
const renderViewportPane = useCallback(() => (
|
||||||
|
<div className="document-viewer__viewport">
|
||||||
|
{!previewEntry?.url ? (
|
||||||
|
<div className="document-viewer__message">{previewLoadingMessage}</div>
|
||||||
|
) : (
|
||||||
|
previewContent
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
), [previewEntry?.url, previewLoadingMessage, previewContent]);
|
||||||
|
|
||||||
|
const viewportPane = renderViewportPane();
|
||||||
|
|
||||||
|
const stackedLeadingTabs = useMemo(() => (
|
||||||
|
isStacked
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'preview',
|
||||||
|
label: 'Preview',
|
||||||
|
render: () => renderViewportPane(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
), [isStacked, renderViewportPane]);
|
||||||
|
|
||||||
|
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
|
||||||
|
const summaryPlacement = 'tabs';
|
||||||
|
const tabsPlacement = isStacked ? 'bottom' : 'top';
|
||||||
|
const summaryLayout = 'compact';
|
||||||
|
|
||||||
|
const detailsPane = (
|
||||||
|
<div className="document-viewer__details-pane">
|
||||||
|
<div className="document-viewer__details">
|
||||||
|
<DocumentInfoPanel
|
||||||
|
document={document}
|
||||||
|
summaryProps={summaryProps}
|
||||||
|
metadataPayload={metadataPayload}
|
||||||
|
contentConfig={contentTabConfig}
|
||||||
|
defaultTabId={resolvedDefaultTabId}
|
||||||
|
classNamePrefix={classNamePrefix}
|
||||||
|
hideTabNavWhenSingle={false}
|
||||||
|
resetKey={resetKey || document?.id}
|
||||||
|
summaryPlacement={summaryPlacement}
|
||||||
|
summaryLayout={summaryLayout}
|
||||||
|
leadingTabs={stackedLeadingTabs}
|
||||||
|
tabsPlacement={tabsPlacement}
|
||||||
|
{...infoPanelProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isStacked) {
|
||||||
|
return detailsPane;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="document-viewer__details-pane">
|
{detailsPane}
|
||||||
<div className="document-viewer__details">
|
{viewportPane}
|
||||||
<DocumentInfoPanel
|
|
||||||
document={document}
|
|
||||||
summaryProps={summaryProps}
|
|
||||||
metadataPayload={metadataPayload}
|
|
||||||
contentConfig={contentTabConfig}
|
|
||||||
defaultTabId={defaultTabId}
|
|
||||||
classNamePrefix={classNamePrefix}
|
|
||||||
hideTabNavWhenSingle={false}
|
|
||||||
resetKey={resetKey || document?.id}
|
|
||||||
{...infoPanelProps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="document-viewer__viewport">
|
|
||||||
{!previewEntry?.url ? (
|
|
||||||
<div className="document-viewer__message">{previewLoadingMessage}</div>
|
|
||||||
) : (
|
|
||||||
previewContent
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ const DocumentViewerPanel = ({
|
|||||||
metadataPayload={metadataPayload}
|
metadataPayload={metadataPayload}
|
||||||
contentTabConfig={contentTabConfig}
|
contentTabConfig={contentTabConfig}
|
||||||
previewLoadingMessage="Loading preview…"
|
previewLoadingMessage="Loading preview…"
|
||||||
|
layoutMode={isStackedLayout ? 'stacked' : 'split'}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
|
||||||
--detail-panel-width: 30em;
|
--detail-panel-width: calc(100vw / 3);
|
||||||
--documents-grid-title-size: 0.8rem;
|
--documents-grid-title-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -489,6 +489,88 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.document-summary--compact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__meta-label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__meta-value {
|
||||||
|
color: var(--fg);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__meta-value .detail-meta__value {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list.document-summary__tags,
|
||||||
|
.correspondent-list.document-summary__correspondents {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__details {
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__details-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__details-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__details-row dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary__details-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-edit,
|
.detail-panel .doc-title-edit,
|
||||||
.document-summary .doc-title-edit {
|
.document-summary .doc-title-edit {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -773,7 +855,7 @@
|
|||||||
|
|
||||||
.detail-panel dt {
|
.detail-panel dt {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 0.8rem;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel dd {
|
.detail-panel dd {
|
||||||
@@ -786,7 +868,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 1rem 0;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 1rem 1rem;
|
padding: 0.5rem 1rem 0.5rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +190,15 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabs-wrapper--bottom .document-viewer__tabpanes {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabs-wrapper--bottom .document-viewer__tabs {
|
||||||
|
order: 2;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.document-viewer__section {
|
.document-viewer__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -268,28 +278,37 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tabs {
|
.document-viewer__tabs {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
border-bottom: 1px solid var(--outline-subtle);
|
padding: 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-overlay, var(--surface));
|
||||||
|
border: 1px solid var(--outline-subtle);
|
||||||
|
box-shadow: var(--panel-shadow-soft, none);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tab {
|
.document-viewer__tab {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.35rem 0.9rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition:
|
transition:
|
||||||
color 120ms ease,
|
color 120ms ease,
|
||||||
border-color 120ms ease;
|
background-color 120ms ease;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tab:hover,
|
.document-viewer__tab:hover,
|
||||||
@@ -298,8 +317,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tab.is-active {
|
.document-viewer__tab.is-active {
|
||||||
color: var(--accent);
|
color: var(--accent-strong, var(--accent));
|
||||||
border-color: var(--accent);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tabpanes {
|
.document-viewer__tabpanes {
|
||||||
@@ -308,6 +327,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer--stacked .document-viewer__tabpanes {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.document-viewer__tabpanes--single {
|
.document-viewer__tabpanes--single {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -345,7 +368,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
grid-area: viewport;
|
grid-area: viewport;
|
||||||
@@ -369,12 +392,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
|
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
|
||||||
height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
|
height: 100%;
|
||||||
max-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer--stacked .document-viewer__object--image {
|
.document-viewer--stacked .document-viewer__object--image {
|
||||||
height: auto;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__unsupported {
|
.document-viewer__unsupported {
|
||||||
@@ -408,3 +430,6 @@
|
|||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
.document-viewer__summary-tab-content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user