Files
LocalAI/core/http/react-ui/src/App.jsx
T
mudler-agentandEttore Di Giacinto 0a44303c21 fix(addons): one page title, one primary action and clearer empty states (#12850)
A visual pass over the add-on pages found the same few problems on
several of them. Fix them with one rule per problem instead of page by
page.

Titles: every add-on page takes the host page-title size. Talk and Tables
no longer shrink it, Studio draws one "Studio" title above its tabs (the
card of a generator is a section), and Voice keeps one title on all its
tabs. The Text to speech tab gets a styled form. The "not available"
page shows the add-on name, not the lowercase id, and sits at the same
inset as an add-on page.

One primary action: the empty state of a list carries the add button, so
the header button waits until there is something in the list (Voice
library, enrolling a speaker or a person, Add rows). The Add-ons page
shows one way into Browse. A table with no columns says so once.

Tables: Share is hidden when there are no named accounts. The active
table tab is highlighted and an empty tab list leaves no gap before
"New table".

Studio: the tab strip stays on one row at 1280 wide, /studio/3d opens
the 3D tab, and an input chip has a divider between its label and its
placeholder.

Fine-tune and Quantize: the Experimental badge sits beside the lede, not
inside the h1; an open step shows its number instead of an empty circle;
the sticky action bar keeps a gap above it.

The footer shows nothing for the empty version of a source build, and
the Voice Text to speech tab says where to get a model when none is
installed.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 12:35:44 +02:00

194 lines
8.0 KiB
React

import { useState, useEffect, useRef, Suspense } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Sidebar from './components/Sidebar'
import OperationsBar from './components/OperationsBar'
import { ToastContainer, useToast } from './components/Toast'
import { systemApi } from './utils/api'
import { useTheme } from './contexts/ThemeContext'
import { useBranding } from './contexts/BrandingContext'
import { useAuth } from './context/AuthContext'
import RouteFallback from './components/RouteFallback'
import { hubs, hubOwnsPath } from './components/hub/hubConfig'
import Icon from './components/Icon'
import { displayVersion } from './utils/version'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
// The page wrapper is keyed so its transition replays on navigation. Within a
// hub, collapse the key to the hub id so the layout (and its tab bar)
// persists across tab-to-tab nav instead of remounting and flashing — only
// the inner page swaps. Normal routes keep their per-path key.
//
// A model's own page (/app/models/<id>) is a child of the Models list, which
// stays mounted behind it so Back finds the list as it was. It shares the
// list's key for the same reason.
function pageTransitionKey(pathname) {
for (const h of hubs) {
if (hubOwnsPath(h, pathname)) return `hub:${h.id}`
}
if (/^\/app\/models(\/|$)/.test(pathname)) return '/app/models'
return pathname
}
export default function App() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
})
const { toasts, addToast, removeToast } = useToast()
const [version, setVersion] = useState('')
const location = useLocation()
const navigate = useNavigate()
const { theme, toggleTheme } = useTheme()
const { authEnabled, user } = useAuth()
const branding = useBranding()
const { t } = useTranslation('nav')
const hamburgerRef = useRef(null)
const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/(chat|runs\/[^/]+)\/?$/)
useEffect(() => {
systemApi.version()
.then(data => setVersion(displayVersion(typeof data === 'string' ? data : (data?.version || ''))))
.catch(() => {})
}, [])
useEffect(() => {
const handler = (e) => setSidebarCollapsed(e.detail.collapsed)
window.addEventListener('sidebar-collapse', handler)
return () => window.removeEventListener('sidebar-collapse', handler)
}, [])
// Scroll to top on route change
useEffect(() => {
window.scrollTo(0, 0)
}, [location.pathname])
// Drawer polish: lock body scroll, close on Escape, return focus to the
// hamburger when the drawer closes. Only engages when the drawer is open;
// desktop and tablet rail mode are unaffected.
useEffect(() => {
if (!sidebarOpen) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e) => { if (e.key === 'Escape') setSidebarOpen(false) }
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
window.removeEventListener('keydown', onKey)
// Restore focus to the trigger so keyboard users land back where
// they invoked the drawer from.
hamburgerRef.current?.focus()
}
}, [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 a hub
// 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' : '',
sidebarCollapsed ? 'sidebar-is-collapsed' : '',
].filter(Boolean).join(' ')
const showAvatar = authEnabled && user
const accountLabel = user?.name || user?.email || t('account')
const themeToggleLabel = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode')
return (
<div className={layoutClasses}>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="main-content" {...(sidebarOpen ? { 'aria-hidden': 'true', inert: '' } : {})}>
<OperationsBar />
{/* Mobile header — primary actions reachable without opening the
drawer. Hamburger is the only way to expand the nav on phones;
theme toggle and account avatar are mirrored from the sidebar
footer so they remain one tap away. */}
<header className="mobile-header">
<button
ref={hamburgerRef}
className="hamburger-btn"
onClick={() => setSidebarOpen(true)}
aria-label={t('openMenu')}
aria-expanded={sidebarOpen}
aria-controls="app-sidebar"
>
<Icon name="menu" />
</button>
<span className="mobile-title">{branding.instanceName}</span>
<div className="mobile-header-actions">
<button
type="button"
className="mobile-header-btn"
onClick={toggleTheme}
aria-label={themeToggleLabel}
title={themeToggleLabel}
>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
</button>
{showAvatar && (
<button
type="button"
className="mobile-header-btn mobile-header-avatar"
onClick={() => navigate('/app/account')}
aria-label={t('accountFor', { name: accountLabel })}
title={accountLabel}
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" />
) : (
<Icon name="user" />
)}
</button>
)}
</div>
</header>
<div className="main-content-inner">
<div className="page-transition" key={pageTransitionKey(location.pathname)}>
{/* Per-route Suspense catches React.lazy chunk loads (router.jsx)
here, inside the App layout. Without it, suspension would bubble
up to main.jsx's outer boundary and unmount the sidebar/header
on every navigation. RouteFallback shows a delayed loader so the
content area isn't blank while a chunk arrives (hub pages get
their own boundary in HubLayout so the tab bar stays put). */}
<Suspense fallback={<RouteFallback />}>
<Outlet context={{ addToast }} />
</Suspense>
</div>
</div>
{!isChatRoute && (
<footer className="app-footer">
<div className="app-footer-inner">
{version && (
<span className="app-footer-version">
{branding.instanceName} <span style={{ fontWeight: 500 }}>{version}</span>
</span>
)}
<div className="app-footer-links">
<a href="https://github.com/mudler/LocalAI" target="_blank" rel="noopener noreferrer">
<Icon name="github" /> {t('footer.github')}
</a>
<a href="https://localai.io" target="_blank" rel="noopener noreferrer">
<Icon name="book" /> {t('footer.documentation')}
</a>
<a href="https://mudler.pm" target="_blank" rel="noopener noreferrer">
<Icon name="user" /> {t('footer.author')}
</a>
</div>
<span className="app-footer-copyright">
&copy; 2023-2026 <a href="https://mudler.pm" target="_blank" rel="noopener noreferrer">Ettore Di Giacinto</a>
</span>
</div>
</footer>
)}
</main>
<ToastContainer toasts={toasts} removeToast={removeToast} />
</div>
)
}