feat(ui): introduce Control Rail design system and shell
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export type IconName =
|
||||
| "activity"
|
||||
| "alert"
|
||||
| "arrow-left"
|
||||
| "audit"
|
||||
| "bookings"
|
||||
| "check"
|
||||
| "chevron"
|
||||
| "clock"
|
||||
| "fleet"
|
||||
| "integrations"
|
||||
| "knowledge"
|
||||
| "logout"
|
||||
| "menu"
|
||||
| "quality"
|
||||
| "search"
|
||||
| "shield"
|
||||
| "spark"
|
||||
| "user"
|
||||
| "x";
|
||||
|
||||
const paths: Record<IconName, JSX.Element> = {
|
||||
activity: <><path d="M3 12h4l2.5-6 5 12 2.5-6h4" /></>,
|
||||
alert: <><path d="M10.3 3.4 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.4a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
|
||||
"arrow-left": <><path d="m15 18-6-6 6-6" /></>,
|
||||
audit: <><path d="M9 4h6" /><path d="M9 2h6v4H9z" /><path d="M7 4H5a2 2 0 0 0-2 2v14h18V6a2 2 0 0 0-2-2h-2" /><path d="m8 13 2 2 5-5" /></>,
|
||||
bookings: <><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M16 3v4M8 3v4M3 10h18" /></>,
|
||||
check: <><path d="m5 12 4 4L19 6" /></>,
|
||||
chevron: <><path d="m9 18 6-6-6-6" /></>,
|
||||
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
||||
fleet: <><path d="m5 16-1 3h2" /><path d="m19 16 1 3h-2" /><path d="M4 16h16l-1.4-6.2A2.3 2.3 0 0 0 16.4 8H7.6a2.3 2.3 0 0 0-2.2 1.8L4 16Z" /><path d="M6 13h12" /><circle cx="7" cy="16" r="1" /><circle cx="17" cy="16" r="1" /></>,
|
||||
integrations: <><circle cx="12" cy="12" r="3" /><circle cx="5" cy="6" r="2" /><circle cx="19" cy="6" r="2" /><circle cx="12" cy="20" r="2" /><path d="m7 7.5 2.7 2.3m4.6 0L17 7.5M12 15v3" /></>,
|
||||
knowledge: <><path d="M4 5.5A3.5 3.5 0 0 1 7.5 2H11v18H7.5A3.5 3.5 0 0 0 4 23V5.5Z" /><path d="M20 5.5A3.5 3.5 0 0 0 16.5 2H13v18h3.5A3.5 3.5 0 0 1 20 23V5.5Z" /></>,
|
||||
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /></>,
|
||||
menu: <><path d="M4 7h16M4 12h16M4 17h16" /></>,
|
||||
quality: <><path d="m12 3 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7L12 3Z" /></>,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
|
||||
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
|
||||
spark: <><path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
|
||||
x: <><path d="m6 6 12 12M18 6 6 18" /></>,
|
||||
};
|
||||
|
||||
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandMark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="currentColor" />
|
||||
<path d="M3 15.5h26" stroke="#2dd4bf" strokeWidth="2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Icon, type IconName } from "./Icons";
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{description && <p className="page-description">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Loading workspace…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="state-panel" role="status">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<Icon name="alert" />
|
||||
<div><strong>We couldn’t load this workspace.</strong><p>{message}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = "check",
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
icon?: IconName;
|
||||
title: string;
|
||||
detail: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="state-panel state-empty">
|
||||
<Icon name={icon} />
|
||||
<div><strong>{title}</strong><p>{detail}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntegrationMark({ kind }: { kind: "n8n" | "rag" | "mcp" }) {
|
||||
const labels = { n8n: "n8n", rag: "RA", mcp: "MC" };
|
||||
return <span className={`integration-mark integration-${kind}`} aria-hidden="true">{labels[kind]}</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user