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 ? ( +
+ { + setTitleDraft(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + aria-label="Document title" + autoFocus + disabled={titleSaving} + /> + + +
+ ) : ( + <> +

{summary.title}

+ {editableTitle ? ( + + ) : null} + + ) + ); + + const issuedDisplay = editableIssued && isIssuedEditing ? ( +
+ { + setIssuedDraft(event.target.value); + if (issuedError) { + setIssuedError(null); + } + }} + aria-label="Issued on" + disabled={issuedSaving} + /> + + +
+ ) : ( + <> + {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 ? ( -
- { - setIssuedDraft(event.target.value); - if (issuedError) { - setIssuedError(null); - } - }} - aria-label="Issued on" - disabled={issuedSaving} - /> - - -
- ) : ( - <> - {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; +}