feat(ui): editorial PageHeader with section eyebrow + scroll-to-top on nav

PageHeader now derives its eyebrow from the route's section/console (Build /
Operate / Create) via sectionKeyForPath, so pages get a consistent, meaningful
eyebrow with no per-page wiring (override with the eyebrow prop, suppress with
eyebrow={null}). Settings adopts it as the first consumer.

Also fix a navigation scroll bug: the default layout uses the document as its
scroll container and route changes did not reset it, so navigating the console
rail from a scrolled page landed mid-view. App now scrolls to top on pathname
change.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-18 14:51:00 +00:00
parent 7cca8352cb
commit a24cc84f7d
4 changed files with 50 additions and 14 deletions

View File

@@ -75,6 +75,14 @@ export default function App() {
}
}, [sidebarOpen])
// Reset scroll to the top on every route change. The default (non-chat)
// layout uses the document as its scroll container, so without this a new
// page opens at the previous page's scroll position - navigating the console
// rail from a scrolled page would land mid-view instead of at the top.
useEffect(() => {
window.scrollTo(0, 0)
}, [location.pathname])
const layoutClasses = [
'app-layout',
isChatRoute ? 'app-layout-chat' : '',

View File

@@ -1,10 +1,20 @@
// Editorial page header: left eyebrow + serif title + supporting line,
// right-aligned meta/actions slot. Asymmetric, left-aligned.
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { sectionKeyForPath } from '../utils/section'
// Editorial page header: left eyebrow + title + supporting line, right-aligned
// meta/actions slot. The eyebrow defaults to the page's section/console name
// (derived from the route) so headers stay consistent without per-page wiring;
// pass `eyebrow` to override, or `eyebrow={null}` to suppress it.
export default function PageHeader({ eyebrow, title, supporting, actions, className = '' }) {
const { t } = useTranslation('nav')
const { pathname } = useLocation()
const autoKey = sectionKeyForPath(pathname)
const resolvedEyebrow = eyebrow !== undefined ? eyebrow : (autoKey ? t(autoKey) : null)
return (
<header className={`page-header page-header--editorial ${className}`.trim()}>
<div className="page-header__lead">
{eyebrow && <span className="page-header__eyebrow">{eyebrow}</span>}
{resolvedEyebrow && <span className="page-header__eyebrow">{resolvedEyebrow}</span>}
{title && <h1 className="page-title">{title}</h1>}
{supporting && <p className="page-header__supporting">{supporting}</p>}
</div>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { settingsApi, resourcesApi, brandingApi } from '../utils/api'
import { useBranding } from '../contexts/BrandingContext'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import SearchableModelSelect from '../components/SearchableModelSelect'
import { CAP_CHAT } from '../utils/capabilities'
import Toggle from '../components/Toggle'
@@ -159,17 +160,16 @@ export default function Settings() {
return (
<div className="page page--medium" style={{ padding: 0 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)',
}}>
<div>
<h1 className="page-title">{t('settings.title')}</h1>
<p className="page-subtitle">{t('settings.subtitle')}</p>
</div>
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isDirty ? 'Save Changes' : 'Saved'}</>}
</button>
<div style={{ padding: 'var(--spacing-lg) var(--spacing-lg) 0' }}>
<PageHeader
title={t('settings.title')}
supporting={t('settings.subtitle')}
actions={
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isDirty ? 'Save Changes' : 'Saved'}</>}
</button>
}
/>
</div>
{/* Two-column layout */}

18
core/http/react-ui/src/utils/section.js vendored Normal file
View File

@@ -0,0 +1,18 @@
import { consoles, consolePaths } from '../components/console/consoleConfig'
// Inline "Create" group from the sidebar (these pages live outside a console).
const CREATE_PATHS = ['/app/chat', '/app/studio', '/app/talk']
// The section/console an app page belongs to, returned as a `nav` i18n key for
// use as the PageHeader eyebrow. Console pages map to their console title
// (Build / Operate); the inline Create group maps to sections.create; any other
// top-level page (Home, Install Models, Account, ...) has no eyebrow.
export function sectionKeyForPath(pathname) {
for (const c of consoles) {
if (consolePaths(c).some(p => pathname === p || pathname.startsWith(p + '/'))) {
return c.titleKey
}
}
if (CREATE_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))) return 'sections.create'
return null
}