diff --git a/frontend/src/documents/DocumentInfoPanel.jsx b/frontend/src/documents/DocumentInfoPanel.jsx
index f1dc4f1..064125a 100644
--- a/frontend/src/documents/DocumentInfoPanel.jsx
+++ b/frontend/src/documents/DocumentInfoPanel.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
@@ -16,6 +16,13 @@ const DocumentInfoPanel = ({
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
+ summaryPlacement = 'inline',
+ summaryTabLabel = 'Summary',
+ summaryTabId = 'summary',
+ leadingTabs = [],
+ trailingTabs = [],
+ tabsPlacement = 'top',
+ summaryLayout = 'default',
}) => {
const base = classNamePrefix;
@@ -92,29 +99,90 @@ const DocumentInfoPanel = ({
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
+ const renderSummarySection = useCallback(() => (
+
+ ), [document, summaryLayout, summaryProps, metadataItems]);
+
+ const renderDetailsSection = useCallback(() => (
+
+ {metadataItems.length ? (
+
+ {metadataItems.map(({ label, value }) => (
+
+
- {label}
+ - {value || '—'}
+
+ ))}
+
+ ) : (
+ No details available.
+ )}
+
+ ), [base, metadataItems]);
+
+ const summaryInline = summaryPlacement !== 'tabs';
+
+ const summaryTab = useMemo(() => {
+ if (summaryPlacement !== 'tabs') {
+ return null;
+ }
+ return {
+ id: summaryTabId,
+ label: summaryTabLabel,
+ render: () => (
+
+ {renderSummarySection()}
+
+ ),
+ };
+ }, [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 tabsList = [];
- tabsList.push({
- id: 'details',
- label: detailsTabLabel,
- render: () => (
-
- {metadataItems.length ? (
-
- {metadataItems.map(({ label, value }) => (
-
-
- {label}
- - {value || '—'}
-
- ))}
-
- ) : (
- No details available.
- )}
-
- ),
- });
+ if (normalizedLeadingTabs.length) {
+ tabsList.push(...normalizedLeadingTabs);
+ }
+
+ if (summaryTab) {
+ tabsList.push(summaryTab);
+ }
+
+ if (summaryPlacement !== 'tabs') {
+ tabsList.push({
+ id: 'details',
+ label: detailsTabLabel,
+ render: () => renderDetailsSection(),
+ });
+ }
if (showContentTab && contentConfig) {
tabsList.push({
@@ -183,30 +251,38 @@ const DocumentInfoPanel = ({
}
if (metadataPayload) {
- tabsList.push({
- id: 'metadata',
- label: metadataTabLabel,
- render: () => (
-
-
- {JSON.stringify(metadataPayload, null, 2)}
-
-
- ),
- });
+ tabsList.push({
+ id: 'metadata',
+ label: metadataTabLabel,
+ render: () => (
+
+
+ {JSON.stringify(metadataPayload, null, 2)}
+
+
+ ),
+ });
+ }
+
+ if (normalizedTrailingTabs.length) {
+ tabsList.push(...normalizedTrailingTabs);
}
return tabsList;
}, [
base,
detailsTabLabel,
- metadataItems,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
+ summaryTab,
+ normalizedLeadingTabs,
+ normalizedTrailingTabs,
+ summaryPlacement,
+ renderDetailsSection,
]);
const fallbackTabId = useMemo(() => {
@@ -242,7 +318,7 @@ const DocumentInfoPanel = ({
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
- }, [fallbackTabId, resetKey, isControlled]);
+ }, [fallbackTabId, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
@@ -270,12 +346,40 @@ const DocumentInfoPanel = ({
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
const shouldHideNav = hideTabNavWhenSingle && singleTab;
+ const tabNav = (
+
+ {visibleTabs.map((tab) => (
+
+ ))}
+
+ );
+
+ const tabPanels = (
+
+ {visibleTabs.map((tab) => (
+ tab.id === activeTabId ? (
+
+ {renderTabContent(tab, { document })}
+
+ ) : null
+ ))}
+
+ );
+
+ const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`;
+
return (
<>
-
+ {summaryNode}
{shouldHideNav ? (
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
) : (
-
-
- {visibleTabs.map((tab) => (
-
- ))}
-
-
- {visibleTabs.map((tab) => (
- tab.id === activeTabId ? (
-
- {renderTabContent(tab, { document })}
-
- ) : null
- ))}
-
+
+ {tabsPlacement !== 'bottom' ? tabNav : null}
+ {tabsPlacement === 'bottom' ? tabPanels : null}
+ {tabsPlacement === 'bottom' ? tabNav : null}
+ {tabsPlacement !== 'bottom' ? tabPanels : null}
)}
>
diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.jsx
index 5793167..1d1319b 100644
--- a/frontend/src/documents/DocumentSummarySection.jsx
+++ b/frontend/src/documents/DocumentSummarySection.jsx
@@ -348,8 +348,11 @@ const DocumentSummarySection = ({
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
- onUpdateIssued
+ onUpdateIssued,
+ layout = 'default',
+ detailItems = [],
}) => {
+ const isCompactLayout = layout === 'compact';
const summary = useMemo(() => {
if (!document) {
return {
@@ -507,6 +510,204 @@ const DocumentSummarySection = ({
return null;
}
+ const TitleSection = () => (
+ editableTitle && isTitleEditing ? (
+
+ ) : (
+ <>
+
{summary.title}
+ {editableTitle ? (
+
+ ) : null}
+ >
+ )
+ );
+
+ const issuedDisplay = editableIssued && isIssuedEditing ? (
+
+ ) : (
+ <>
+
{issuedDateLabel || 'Not set'}
+ {editableIssued ? (
+
+ ) : 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 = () => (
+
+ onTagRemove(document.id, tag.id)
+ : undefined
+ }
+ onAdd={
+ onTagAdd
+ ? ({ value, option }) => onTagAdd(document, value, { option })
+ : undefined
+ }
+ datalistOptions={tagOptions}
+ className="document-summary__tags"
+ />
+
+ );
+
+ const renderCorrespondents = () => (
+
+
+ onCorrespondentRemove({
+ documentId: document.id,
+ correspondentId: entry.id,
+ })
+ : undefined
+ }
+ onAdd={
+ onCorrespondentAdd
+ ? ({ name, option }) =>
+ onCorrespondentAdd({
+ document,
+ name,
+ option,
+ })
+ : undefined
+ }
+ showCount
+ datalistOptions={correspondentOptions}
+ className="document-summary__correspondents"
+ />
+
+ );
+
+ if (isCompactLayout) {
+ return (
+
+
+ {titleError ?
{titleError}
: null}
+ {renderTags()}
+ {renderCorrespondents()}
+ {compactRows.length ? (
+
+
+ {compactRows.map((item) => (
+
+
- {item.label}
+
-
+ {item.valueContent != null && item.valueContent !== ''
+ ? item.valueContent
+ : item.fallbackValue || '—'}
+
+ {item.error ?
{item.error}
: null}
+
+ ))}
+
+
+ ) : null}
+
+ );
+ }
+
return (
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
- title="Edit title"
- >
-
-
- ) : null}
- >
- )}
-
+ title="Edit title"
+ >
+
+
+ ) : null}
+ >
+ )}
+
{titleError ? {titleError}
: null}
Issued:
- {editableIssued && isIssuedEditing ? (
-
- ) : (
- <>
- {issuedDateLabel || 'Not set'}
- {editableIssued ? (
-
- ) : null}
- >
- )}
+ {issuedDisplay}
{issuedError ?
{issuedError}
: null}
@@ -619,45 +779,9 @@ const DocumentSummarySection = ({
))}
- onTagRemove(document.id, tag.id)
- : undefined
- }
- onAdd={
- onTagAdd
- ? ({ value, option }) => onTagAdd(document, value, { option })
- : undefined
- }
- datalistOptions={tagOptions}
- />
+ {renderTags()}
-
- onCorrespondentRemove({
- documentId: document.id,
- correspondentId: entry.id,
- })
- : undefined
- }
- onAdd={
- onCorrespondentAdd
- ? ({ name, option }) =>
- onCorrespondentAdd({
- document,
- name,
- option,
- })
- : undefined
- }
- showCount
- datalistOptions={correspondentOptions}
- />
+ {renderCorrespondents()}
);
};
diff --git a/frontend/src/preview/DocumentViewerLayout.jsx b/frontend/src/preview/DocumentViewerLayout.jsx
index 964e5ea..1389c5a 100644
--- a/frontend/src/preview/DocumentViewerLayout.jsx
+++ b/frontend/src/preview/DocumentViewerLayout.jsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
@@ -13,7 +13,10 @@ const DocumentViewerLayout = ({
defaultTabId = 'details',
infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…',
+ layoutMode = 'split',
}) => {
+ const isStacked = layoutMode === 'stacked';
+
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
@@ -74,30 +77,65 @@ const DocumentViewerLayout = ({
);
}, [previewEntry, document]);
+ const renderViewportPane = useCallback(() => (
+
+ {!previewEntry?.url ? (
+
{previewLoadingMessage}
+ ) : (
+ previewContent
+ )}
+
+ ), [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 = (
+
+ );
+
+ if (isStacked) {
+ return detailsPane;
+ }
+
return (
<>
-
-
- {!previewEntry?.url ? (
-
{previewLoadingMessage}
- ) : (
- previewContent
- )}
-
+ {detailsPane}
+ {viewportPane}
>
);
};
diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx
index 8a5cb61..2d92071 100644
--- a/frontend/src/preview/DocumentViewerPanel.jsx
+++ b/frontend/src/preview/DocumentViewerPanel.jsx
@@ -432,6 +432,7 @@ const DocumentViewerPanel = ({
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
+ layoutMode={isStackedLayout ? 'stacked' : 'split'}
/>
diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css
index 7f648c6..5bbee84 100644
--- a/frontend/src/styles/base/theme.css
+++ b/frontend/src/styles/base/theme.css
@@ -100,7 +100,7 @@
font-size: 15px;
--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;
}
diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css
index f5584c6..b718ca9 100644
--- a/frontend/src/styles/detail/detail-panels.css
+++ b/frontend/src/styles/detail/detail-panels.css
@@ -489,6 +489,88 @@
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,
.document-summary .doc-title-edit {
display: flex;
@@ -773,7 +855,7 @@
.detail-panel dt {
font-weight: 600;
- margin-top: 0.8rem;
+ margin-top: 0;
}
.detail-panel dd {
@@ -786,7 +868,6 @@
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
- margin: 1rem 0;
align-items: center;
}
diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css
index 0037025..6d2dfbe 100644
--- a/frontend/src/styles/documents/viewer.css
+++ b/frontend/src/styles/documents/viewer.css
@@ -43,7 +43,7 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
- padding: 0.5rem 1rem 1rem;
+ padding: 0.5rem 1rem 0.5rem;
overflow: auto;
}
@@ -53,7 +53,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
- max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
+ flex: 1;
+ min-height: 0;
order: -1;
}
@@ -189,6 +190,15 @@
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 {
display: flex;
flex-direction: column;
@@ -268,28 +278,37 @@
font-size: 0.85rem;
line-height: 1.35;
overflow: auto;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
}
.document-viewer__tabs {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- border-bottom: 1px solid var(--outline-subtle);
+ display: flex;
+ align-items: stretch;
+ gap: 0.25rem;
+ 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 {
appearance: none;
border: none;
background: transparent;
- padding: 0.4rem 0.75rem;
+ padding: 0.35rem 0.9rem;
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;
+ background-color 120ms ease;
+ border-radius: 999px;
+ flex: 1;
+ text-align: center;
}
.document-viewer__tab:hover,
@@ -298,8 +317,8 @@
}
.document-viewer__tab.is-active {
- color: var(--accent);
- border-color: var(--accent);
+ color: var(--accent-strong, var(--accent));
+ background: var(--accent-soft);
}
.document-viewer__tabpanes {
@@ -308,6 +327,10 @@
display: flex;
}
+.document-viewer--stacked .document-viewer__tabpanes {
+ overflow: auto;
+}
+
.document-viewer__tabpanes--single {
flex: 1;
min-height: 0;
@@ -345,7 +368,7 @@
display: flex;
position: relative;
overflow: hidden;
- align-items: flex-start;
+ align-items: stretch;
justify-content: flex-start;
max-height: 100%;
grid-area: viewport;
@@ -369,12 +392,11 @@
}
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
- height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
- max-height: 100%;
+ height: 100%;
}
.document-viewer--stacked .document-viewer__object--image {
- height: auto;
+ max-height: 100%;
}
.document-viewer__unsupported {
@@ -408,3 +430,6 @@
width: 1rem;
height: 1rem;
}
+.document-viewer__summary-tab-content {
+ padding-top: 0.5rem;
+}