mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add sidebar
This commit is contained in:
parent
7db2703b33
commit
e5d2815b41
41 changed files with 2469 additions and 1391 deletions
691
web/src/App.tsx
691
web/src/App.tsx
|
|
@ -1,31 +1,47 @@
|
|||
import { useMemo } from "react";
|
||||
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
Code,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Globe,
|
||||
Heart,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Globe,
|
||||
Database,
|
||||
RotateCw,
|
||||
Settings,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
Sparkles,
|
||||
Star,
|
||||
Code,
|
||||
Eye,
|
||||
Terminal,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
|
|
@ -36,15 +52,17 @@ import SkillsPage from "@/pages/SkillsPage";
|
|||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { RegisteredPlugin } from "@/plugins";
|
||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
|
||||
/** Built-in route → default page component. Used both for standard routing
|
||||
* and for resolving plugin `tab.override` values. Keys must match the
|
||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||
"/": StatusPage,
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
}
|
||||
|
||||
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
|
||||
const BUILTIN_ROUTES: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/logs": LogsPage,
|
||||
|
|
@ -55,7 +73,6 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
|||
};
|
||||
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
{
|
||||
path: "/sessions",
|
||||
labelKey: "sessions",
|
||||
|
|
@ -75,9 +92,7 @@ const BUILTIN_NAV: NavItem[] = [
|
|||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||
];
|
||||
|
||||
// Plugins can reference any of these by name in their manifest — keeps bundle
|
||||
// size sane vs. importing the full lucide-react set.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
|
|
@ -100,24 +115,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
|||
Eye,
|
||||
};
|
||||
|
||||
function resolveIcon(
|
||||
name: string,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
function resolveIcon(name: string): ComponentType<{ className?: string }> {
|
||||
return ICON_MAP[name] ?? Puzzle;
|
||||
}
|
||||
|
||||
function buildNavItems(
|
||||
builtIn: NavItem[],
|
||||
plugins: RegisteredPlugin[],
|
||||
): NavItem[] {
|
||||
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
|
||||
const items = [...builtIn];
|
||||
|
||||
for (const { manifest } of plugins) {
|
||||
// Plugins that replace a built-in route don't add a new tab entry —
|
||||
// they reuse the existing tab. The nav just lights up the original
|
||||
// built-in entry when the user visits `/`.
|
||||
for (const manifest of manifests) {
|
||||
if (manifest.tab.override) continue;
|
||||
// Hidden plugins register their component + slots but skip the nav.
|
||||
if (manifest.tab.hidden) continue;
|
||||
|
||||
const pluginItem: NavItem = {
|
||||
|
|
@ -145,54 +151,58 @@ function buildNavItems(
|
|||
return items;
|
||||
}
|
||||
|
||||
/** Build the final route table, letting plugins override built-in pages.
|
||||
*
|
||||
* Returns (path, Component, key) tuples. Plugins with `tab.override`
|
||||
* win over both built-ins and other plugins (last registration wins if
|
||||
* two plugins claim the same override, but we warn in dev). Plugins with
|
||||
* a regular `tab.path` register alongside built-ins as standalone
|
||||
* routes. */
|
||||
function buildRoutes(
|
||||
plugins: RegisteredPlugin[],
|
||||
): Array<{ key: string; path: string; Component: React.ComponentType }> {
|
||||
const overrides = new Map<string, RegisteredPlugin>();
|
||||
const addons: RegisteredPlugin[] = [];
|
||||
function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
element: ReactNode;
|
||||
}> {
|
||||
const byOverride = new Map<string, PluginManifest>();
|
||||
const addons: PluginManifest[] = [];
|
||||
|
||||
for (const p of plugins) {
|
||||
if (p.manifest.tab.override) {
|
||||
overrides.set(p.manifest.tab.override, p);
|
||||
for (const m of manifests) {
|
||||
if (m.tab.override) {
|
||||
byOverride.set(m.tab.override, m);
|
||||
} else {
|
||||
addons.push(p);
|
||||
addons.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
Component: React.ComponentType;
|
||||
element: ReactNode;
|
||||
}> = [];
|
||||
|
||||
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
||||
const override = overrides.get(path);
|
||||
if (override) {
|
||||
const om = byOverride.get(path);
|
||||
if (om) {
|
||||
routes.push({
|
||||
key: `override:${override.manifest.name}`,
|
||||
key: `override:${om.name}`,
|
||||
path,
|
||||
Component: override.component,
|
||||
element: <PluginPage name={om.name} />,
|
||||
});
|
||||
} else {
|
||||
routes.push({ key: `builtin:${path}`, path, Component });
|
||||
routes.push({ key: `builtin:${path}`, path, element: <Component /> });
|
||||
}
|
||||
}
|
||||
|
||||
for (const addon of addons) {
|
||||
// Don't double-register a plugin that shadows a built-in path via
|
||||
// `tab.path` — `override` is the supported mechanism for that.
|
||||
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
|
||||
for (const m of addons) {
|
||||
if (m.tab.hidden) continue;
|
||||
if (BUILTIN_ROUTES[m.tab.path]) continue;
|
||||
routes.push({
|
||||
key: `plugin:${addon.manifest.name}`,
|
||||
path: addon.manifest.tab.path,
|
||||
Component: addon.component,
|
||||
key: `plugin:${m.name}`,
|
||||
path: m.tab.path,
|
||||
element: <PluginPage name={m.name} />,
|
||||
});
|
||||
}
|
||||
|
||||
for (const m of manifests) {
|
||||
if (!m.tab.hidden) continue;
|
||||
if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue;
|
||||
routes.push({
|
||||
key: `plugin:hidden:${m.name}`,
|
||||
path: m.tab.path,
|
||||
element: <PluginPage name={m.name} />,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -201,154 +211,125 @@ function buildRoutes(
|
|||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { plugins } = usePlugins();
|
||||
const { manifests } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(BUILTIN_NAV, plugins),
|
||||
[plugins],
|
||||
() => buildNavItems(BUILTIN_NAV, manifests),
|
||||
[manifests],
|
||||
);
|
||||
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
|
||||
const pluginTabMeta = useMemo(
|
||||
() =>
|
||||
manifests
|
||||
.filter((m) => !m.tab.hidden)
|
||||
.map((m) => ({
|
||||
path: m.tab.override ?? m.tab.path,
|
||||
label: m.label,
|
||||
})),
|
||||
[manifests],
|
||||
);
|
||||
const routes = useMemo(() => buildRoutes(plugins), [plugins]);
|
||||
|
||||
const layoutVariant = theme.layoutVariant ?? "standard";
|
||||
const showSidebar = layoutVariant === "cockpit";
|
||||
// Tiled layout drops the 1600px clamp so pages can use the full viewport;
|
||||
// standard + cockpit keep the centered reading width.
|
||||
const mainMaxWidth = layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
|
||||
const mainMaxWidth =
|
||||
layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMobileOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(min-width: 1024px)");
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) setMobileOpen(false);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden"
|
||||
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
|
||||
>
|
||||
<SelectionSwitcher />
|
||||
<Backdrop />
|
||||
{/* Themes can style backdrop chrome via `componentStyles.backdrop.*`
|
||||
CSS vars read by <Backdrop />. Plugins can also inject full
|
||||
components into the backdrop layer via the `backdrop` slot —
|
||||
useful for scanlines, parallax stars, hero artwork, etc. */}
|
||||
<PluginSlot name="backdrop" />
|
||||
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-40",
|
||||
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
|
||||
"flex items-center gap-2 px-3",
|
||||
"border-b border-current/20",
|
||||
"bg-background-base/90 backdrop-blur-sm",
|
||||
)}
|
||||
style={{
|
||||
// Themes can tweak header chrome (background, border-image,
|
||||
// clip-path) via these CSS vars. Unset vars compute to the
|
||||
// property's initial value, so themes opt in per-property.
|
||||
background: "var(--component-header-background)",
|
||||
borderImage: "var(--component-header-border-image)",
|
||||
clipPath: "var(--component-header-clip-path)",
|
||||
}}
|
||||
>
|
||||
<div className={cn("mx-auto flex h-12", mainMaxWidth)}>
|
||||
<PluginSlot name="header-left" />
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
||||
<Grid
|
||||
className="h-full !border-t-0 !border-b-0"
|
||||
style={{
|
||||
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
|
||||
}}
|
||||
>
|
||||
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
|
||||
<Typography
|
||||
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
</Typography>
|
||||
</Cell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label={t.app.openNavigation}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="app-sidebar"
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 items-center justify-center",
|
||||
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
||||
<Cell key={path} className="relative !p-0">
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex h-full w-full items-center gap-1.5",
|
||||
"px-2.5 sm:px-4 py-2",
|
||||
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
{labelKey
|
||||
? ((t.app.nav as Record<string, string>)[
|
||||
labelKey
|
||||
] ?? label)
|
||||
: label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</Cell>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
|
||||
>
|
||||
{t.app.webUi}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</div>
|
||||
<Typography
|
||||
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.brand}
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
{/* Full-width banner slot under the nav, outside the main clamp —
|
||||
useful for marquee/alert/status strips themes want to show
|
||||
above page content. */}
|
||||
{mobileOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t.app.closeNavigation}
|
||||
onClick={closeMobile}
|
||||
className={cn(
|
||||
"lg:hidden fixed inset-0 z-40",
|
||||
"bg-black/60 backdrop-blur-sm cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PluginSlot name="header-banner" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
|
||||
mainMaxWidth,
|
||||
showSidebar && "flex gap-4 sm:gap-6",
|
||||
)}
|
||||
>
|
||||
{showSidebar && (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<aside
|
||||
id="app-sidebar"
|
||||
aria-label={t.app.navigation}
|
||||
className={cn(
|
||||
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4",
|
||||
"hidden lg:block",
|
||||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||
"border-r border-current/20",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-transform duration-200 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-sidebar-background)",
|
||||
|
|
@ -356,75 +337,305 @@ export default function App() {
|
|||
borderImage: "var(--component-sidebar-border-image)",
|
||||
}}
|
||||
>
|
||||
<PluginSlot
|
||||
name="sidebar"
|
||||
fallback={
|
||||
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide">
|
||||
{/* Cockpit layout with no sidebar plugin — rare but valid;
|
||||
the space still exists so the grid doesn't shift when
|
||||
a plugin loads asynchronously. */}
|
||||
sidebar slot empty
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 px-5",
|
||||
"border-b border-current/20",
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
</Typography>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<PluginSlot name="pre-main" />
|
||||
<Routes>
|
||||
{routes.map(({ key, path, Component }) => (
|
||||
<Route key={key} path={path} element={<Component />} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<PluginSlot name="post-main" />
|
||||
</main>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMobile}
|
||||
aria-label={t.app.closeNavigation}
|
||||
className={cn(
|
||||
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
|
||||
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-b border-current/10">
|
||||
<PluginSlot name="header-left" />
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-2"
|
||||
aria-label={t.app.navigation}
|
||||
>
|
||||
<ul className="flex flex-col">
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
||||
<li key={path}>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{labelKey
|
||||
? ((t.app.nav as Record<string, string>)[
|
||||
labelKey
|
||||
] ?? label)
|
||||
: label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<SidebarSystemActions onNavigate={closeMobile} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"px-3 py-2",
|
||||
"border-t border-current/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher dropUp />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-between gap-2",
|
||||
"border-t border-current/20",
|
||||
"px-5 py-3",
|
||||
)}
|
||||
>
|
||||
<PluginSlot
|
||||
name="footer-left"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.brand}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<PluginSlot
|
||||
name="footer-right"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
|
||||
<div className="hidden shrink-0 lg:block lg:w-64" aria-hidden />
|
||||
|
||||
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
||||
<main
|
||||
className={cn(
|
||||
"relative z-2 min-w-0 min-h-0 flex-1",
|
||||
"overflow-y-auto",
|
||||
"px-3 pb-4 sm:px-6 sm:pb-8",
|
||||
"pt-2 sm:pt-4 lg:pt-6",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="pre-main" />
|
||||
<div className={cn("mx-auto w-full", mainMaxWidth)}>
|
||||
<Routes>
|
||||
{routes.map(({ key, path, element }) => (
|
||||
<Route key={key} path={path} element={element} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/sessions" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<PluginSlot name="post-main" />
|
||||
</main>
|
||||
</PageHeaderProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="relative z-2 border-t border-current/20">
|
||||
<Grid className={cn("mx-auto !border-t-0 !border-b-0", mainMaxWidth)}>
|
||||
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
|
||||
<PluginSlot
|
||||
name="footer-left"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
|
||||
<PluginSlot
|
||||
name="footer-right"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</footer>
|
||||
|
||||
{/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render
|
||||
above everything else. Each plugin is responsible for its own
|
||||
pointer-events and z-index. */}
|
||||
<PluginSlot name="overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
useSystemActions();
|
||||
|
||||
const items: SystemActionItem[] = [
|
||||
{
|
||||
action: "restart",
|
||||
icon: RotateCw,
|
||||
label: t.status.restartGateway,
|
||||
runningLabel: t.status.restartingGateway,
|
||||
spin: true,
|
||||
},
|
||||
{
|
||||
action: "update",
|
||||
icon: Download,
|
||||
label: t.status.updateHermes,
|
||||
runningLabel: t.status.updatingHermes,
|
||||
spin: false,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClick = (action: SystemAction) => {
|
||||
if (isBusy) return;
|
||||
void runAction(action);
|
||||
navigate("/sessions");
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col",
|
||||
"border-t border-current/10",
|
||||
"py-1",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-0.5 pb-0.5",
|
||||
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
||||
)}
|
||||
>
|
||||
{t.app.system}
|
||||
</span>
|
||||
|
||||
<SidebarStatusStrip />
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
|
||||
const isPending = pendingAction === action;
|
||||
const isActionRunning =
|
||||
activeAction === action && isRunning && !isPending;
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
const disabled = isBusy && !busy;
|
||||
|
||||
return (
|
||||
<li key={action}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClick(action)}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3",
|
||||
"px-5 py-1.5",
|
||||
"font-mondwest text-[0.75rem] tracking-[0.1em]",
|
||||
"text-left whitespace-nowrap transition-opacity cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
busy
|
||||
? "text-midground opacity-100"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && spin && "animate-spin",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SystemActionItem {
|
||||
action: SystemAction;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
runningLabel: string;
|
||||
spin: boolean;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue