mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-18 21:58:58 -04:00
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:
@@ -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' : '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
18
core/http/react-ui/src/utils/section.js
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user