panelheader

This commit is contained in:
2025-10-31 13:12:09 +01:00
parent 6b4a4af5f1
commit 321b0d84ae
6 changed files with 305 additions and 202 deletions
+19 -11
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
import { useWorkspaceSurface } from './useWorkspaceSurface';
import PanelHeader from '../ui/PanelHeader';
const DocumentsRoute = () => {
const {
@@ -103,6 +104,17 @@ const DocumentsRoute = () => {
const header = surface.header || null;
const headerTitle = header
? header.subtitle
? (
<h2 className="main-content__title">
{header.title}
<span className="main-content__subtitle">{header.subtitle}</span>
</h2>
)
: header.title
: null;
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
@@ -110,17 +122,13 @@ const DocumentsRoute = () => {
>
<div className={mainContentClass}>
{header ? (
<div className="panel-header main-content__header">
{header.leading}
<h2 className="main-content__title">
{header.title}
{header.subtitle ? (
<span className="main-content__subtitle">{header.subtitle}</span>
) : null}
</h2>
<div className="panel-actions__spacer" />
{header.actions}
</div>
<PanelHeader
className="main-content__header"
leading={header.leading}
title={headerTitle}
titleTag="h2"
actions={header.actions}
/>
) : null}
<div className={bodyClass}>{surface.content}</div>
{surface.detail || null}
+116 -85
View File
@@ -9,6 +9,7 @@ import {
WindowMaximizeIcon,
TextScanIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
@@ -1239,95 +1240,125 @@ const DetailPanel = ({
const isBulkSelection = selectedCount > 1;
const showOcrAction = Boolean(singleDoc && singleHasOcr);
const headerLeading = [
(
<button
key="close"
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
),
];
if (singleDoc) {
headerLeading.push(
<button
key="preview"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>,
);
}
const headerActions = [];
if (isBulkSelection && onBulkReanalyze) {
headerActions.push(
<button
key="bulk-reanalyze"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onBulkReanalyze(bulkDocumentIds);
}}
aria-label="Re-run analysis for selection"
title="Re-run analysis for selection"
>
<AnalyzeIcon />
</button>,
);
}
if (singleDoc && singleDownloadHref) {
headerActions.push(
<a
key="download"
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>,
);
}
if (showOcrAction) {
headerActions.push(
<button
key="ocr"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
openOcr().catch(() => {});
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>,
);
}
if (singleDoc) {
headerActions.push(
<button
key="reanalyze"
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onRegenerateThumbnails(singleDoc.id);
}}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>,
);
}
return (
<>
<aside className="detail-panel panel">
<div className="panel-header">
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
{singleDoc ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
<h3 className="panel-header__title">{headerTitle}</h3>
<div className="panel-actions__spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onBulkReanalyze(bulkDocumentIds);
}}
aria-label="Re-run analysis for selection"
title="Re-run analysis for selection"
>
<AnalyzeIcon />
</button>
) : null}
{singleDoc && singleDownloadHref ? (
<a
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>
) : null}
{showOcrAction ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
openOcr().catch(() => {});
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
onRegenerateThumbnails(singleDoc.id);
}}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
) : null}
<PanelHeader
leading={headerLeading}
title={headerTitle}
titleTag="h3"
actions={headerActions.length ? headerActions : null}
/>
<div className="panel-body">
{selectedCount <= 1 ? renderSingle() : renderBulk()}
</div>
<div className="panel-body">
{selectedCount <= 1 ? renderSingle() : renderBulk()}
</div>
</aside>
<PreviewZoomOverlay
open={Boolean(zoomDisplay?.url)}
+7 -7
View File
@@ -172,13 +172,13 @@ export const createPreviewWorkspaceHeaderActions = ({
</a>
) : null}
{hasOcr ? (
<button
type="button"
className="icon-button"
onClick={() => {
openOcr().catch(() => {});
}}
aria-label="View OCR text"
<button
type="button"
className="icon-button"
onClick={() => {
openOcr().catch(() => {});
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
+94 -82
View File
@@ -13,6 +13,7 @@ import {
FolderPlusIcon,
RestoreIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
import { getTagColorStyle } from '../utils/colors';
@@ -351,90 +352,101 @@ const Sidebar = ({
const rootNode = folderNodes.get('root');
return (
<aside className="sidebar">
<div className="panel-header sidebar__header">
<button
type="button"
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
onClick={toggleTenantMenu}
aria-haspopup="true"
aria-expanded={tenantMenuOpen}
ref={tenantButtonRef}
>
<span className="sidebar__title">
Papercrate
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
{tenantMenuOpen ? (
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
>
{showTenantList ? (
<>
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name;
return (
<button
key={tenantId || tenantLabel}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
<PanelHeader
className="sidebar__header"
leading={(
<>
<button
type="button"
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
onClick={toggleTenantMenu}
aria-haspopup="true"
aria-expanded={tenantMenuOpen}
ref={tenantButtonRef}
>
<span className="sidebar__title">
Papercrate
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
{tenantMenuOpen ? (
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
>
{showTenantList ? (
<>
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name;
return (
<button
key={tenantId || tenantLabel}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
</div>
</>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__settings"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</>
</div>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__settings"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
) : null}
<div className="panel-actions__spacer" />
{onCollapse ? (
<button
type="button"
className="icon-button"
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<ChevronsLeftIcon />
</button>
) : null}
</div>
</>
)}
actions={
onCollapse
? [
(
<button
key="collapse"
type="button"
className="icon-button"
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<ChevronsLeftIcon />
</button>
),
]
: null
}
/>
<div className="panel-body sidebar__body">
{status && (
<div className="sidebar__status">
+16 -17
View File
@@ -1223,6 +1223,7 @@ button.danger:hover:not([disabled]) {
font-size: 1rem;
font-weight: 600;
color: var(--fg);
padding-left: 0.25rem;
}
.sidebar__tenant {
@@ -2198,11 +2199,17 @@ button.danger:hover:not([disabled]) {
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem;
}
.panel-header__leading,
.panel-header__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.panel-header__title {
margin: 0;
font-size: 1rem;
@@ -2215,26 +2222,18 @@ button.danger:hover:not([disabled]) {
min-width: 0;
}
.panel-header > .panel-header__title:first-child {
padding-left: 0.5rem;
}
.panel-header__actions {
margin-left: auto;
}
.panel-body {
padding: 0.5rem 1rem;
}
.panel-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
}
.panel-actions__spacer {
flex-grow: 1;
}
.panel-actions > h1:first-child,
.panel-actions > h2:first-child {
padding-left: 0.5rem;
}
.detail-panel .panel-body {
flex: 1;
display: flex;
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
const PanelHeader = ({
className = '',
leading = null,
title = null,
titleTag = 'h2',
titleProps = {},
actions = null,
}) => {
const headerClassName = composeClassName('panel-header', className);
const renderTitle = () => {
if (title == null) {
return null;
}
if (React.isValidElement(title)) {
if (title.type === React.Fragment) {
return (
<span className="panel-header__title" {...titleProps}>
{title}
</span>
);
}
const mergedClassName = composeClassName('panel-header__title', title.props.className || '');
return React.cloneElement(title, {
...titleProps,
className: mergedClassName,
});
}
const HeadingTag = titleTag;
return (
<HeadingTag className="panel-header__title" {...titleProps}>
{title}
</HeadingTag>
);
};
return (
<div className={headerClassName}>
{leading ? <div className="panel-header__leading">{leading}</div> : null}
{renderTitle()}
{actions ? <div className="panel-header__actions">{actions}</div> : null}
</div>
);
};
export default PanelHeader;