feat(ui): introduce Control Rail design system and shell

This commit is contained in:
NuklearRabbit
2026-08-02 03:27:00 +02:00
parent 73e361002d
commit d0f34e6933
5 changed files with 526 additions and 296 deletions
+96 -39
View File
@@ -1,19 +1,34 @@
import { useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { BrandMark, Icon, type IconName } from "./Icons";
const NAV_ITEMS = [
{ to: "/dashboard", label: "Dashboard" },
{ to: "/vehicles", label: "Vehicles" },
{ to: "/bookings", label: "Bookings" },
{ to: "/data-quality", label: "Data Quality" },
{ to: "/knowledge", label: "Knowledge" },
{ to: "/automation", label: "Automation" },
{ to: "/audit", label: "Audit" },
const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: string; shortLabel: string; icon: IconName }> }> = [
{
label: "Operate",
items: [
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality" },
],
},
{
label: "Assure",
items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" },
],
},
];
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
export function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
function handleLogout() {
logout();
@@ -22,38 +37,80 @@ export function Layout() {
return (
<div className="app-shell">
<p className="demo-banner">
Synthetic demo environment no real customer or vehicle data.
</p>
<header className="app-header">
<span className="brand">MobilityOps</span>
<nav aria-label="Primary">
<ul>
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink to={item.to} className={({ isActive }) => (isActive ? "active" : "")}>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="user-badge">
{user && (
<>
<span>
{user.display_name} · {user.role === "operations_manager" ? "Operations Manager" : "Rental Employee"}
</span>
<button type="button" onClick={handleLogout}>
Switch role
</button>
</>
)}
<a className="skip-link" href="#main-content">Skip to main content</a>
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup">
<BrandMark className="brand-mark" />
<div><strong>MobilityOps</strong><span>Control centre</span></div>
</div>
</header>
<main id="main-content">
<Outlet />
</main>
<nav aria-label="Primary navigation">
{NAV_GROUPS.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
<ul>
{group.items.map((item) => (
<li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} />
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="sidebar-foot">
<span className="environment-dot" />
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
</div>
</aside>
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
<div className="app-workspace">
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" />
</button>
<label className="global-search">
<Icon name="search" />
<span className="visually-hidden">Search MobilityOps</span>
<input type="search" placeholder="Search fleet, booking or customer…" />
<kbd>Ctrl K</kbd>
</label>
<div className="topbar-meta">
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
{user && (
<div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? "Operations manager" : "Rental employee"}</small></span>
</div>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role">
<Icon name="logout" />
</button>
</div>
</header>
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
</div>
<nav className="mobile-nav" aria-label="Mobile navigation">
{MOBILE_ITEMS.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{item.shortLabel}</span>
</NavLink>
))}
<button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" />
<span>More</span>
</button>
</nav>
</div>
);
}