Files
wakey/ui/src/layout/AppLayout.tsx
T

255 lines
7.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { useTheme } from "next-themes";
import {
Moon,
Sun,
Monitor,
Zap,
BarChart3,
Bell,
Bot,
FileText,
Key,
Terminal,
SquareTerminal,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_KEY = "wakey-sidebar-collapsed";
type NavSection = {
label: string;
items: readonly {
to: string;
label: string;
icon: React.ElementType;
end?: boolean;
}[];
};
const navSections: NavSection[] = [
{
label: "Operations",
items: [
{ to: "/", label: "Devices", icon: Monitor, end: true },
{ to: "/wake", label: "Wake Tools", icon: Zap },
],
},
{
label: "Monitoring",
items: [
{ to: "/dashboard", label: "Fleet Health", icon: BarChart3 },
{ to: "/alerts", label: "Alerts", icon: Bell },
],
},
{
label: "Admin",
items: [
{ to: "/agents", label: "Agents", icon: Bot },
{ to: "/audit", label: "Audit", icon: FileText },
{ to: "/tokens", label: "Tokens", icon: Key },
{ to: "/commands", label: "Commands", icon: Terminal },
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
],
},
];
function usePageTitle() {
const { pathname } = useLocation();
useEffect(() => {
const flat = navSections.flatMap((s) => s.items);
const match = flat.find((item) =>
item.end
? pathname === "/ui" || pathname === "/ui/" || pathname === "/"
: pathname.startsWith(item.to) && item.to !== "/",
);
document.title = match ? `${match.label} — Wakey` : "Wakey Operator UI";
}, [pathname]);
}
export function AppLayout() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const [collapsed, setCollapsed] = useState(() => {
return window.localStorage.getItem(SIDEBAR_KEY) === "true";
});
usePageTitle();
useEffect(() => {
window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
}, [collapsed]);
return (
<div className="app-shell">
<aside
className={[
"sidebar",
collapsed ? "sidebar--collapsed" : "",
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-100 ease-in-out",
"md:sticky md:top-0 md:z-30 md:shadow-none",
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
]
.filter(Boolean)
.join(" ")}
data-collapsed={collapsed}
>
<div className="sidebar-brand">
<div className="sidebar-logo">
<Zap className="size-5 text-primary" />
</div>
{!collapsed && (
<div className="sidebar-brand-text">
<span className="text-sm font-semibold tracking-tight">
Wakey
</span>
<span className="text-[0.65rem] leading-none text-muted-foreground">
Operator UI
</span>
</div>
)}
</div>
<nav className="sidebar-nav">
{navSections.map((section) => (
<div key={section.label} className="sidebar-section">
{!collapsed && (
<div className="sidebar-section-label">{section.label}</div>
)}
{section.items.map((item) => {
const Icon = item.icon;
const link = (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
[
"sidebar-link",
isActive ? "sidebar-link--active" : "",
collapsed ? "sidebar-link--collapsed" : "",
]
.filter(Boolean)
.join(" ")
}
>
<Icon className="sidebar-link-icon" />
{!collapsed && (
<span className="sidebar-link-label">{item.label}</span>
)}
</NavLink>
);
if (collapsed) {
return (
<Tooltip key={item.to}>
<TooltipTrigger>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
return link;
})}
</div>
))}
</nav>
<div className="sidebar-footer">
<Tooltip>
<TooltipTrigger>
<Button
type="button"
variant="ghost"
size="sm"
className="sidebar-footer-btn"
aria-label={isDark ? "Switch to light" : "Switch to dark"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? (
<Sun className="size-4" aria-hidden />
) : (
<Moon className="size-4" aria-hidden />
)}
{!collapsed && (
<span className="sidebar-link-label">
{isDark ? "Light mode" : "Dark mode"}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{isDark ? "Switch to light" : "Switch to dark"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
type="button"
variant="ghost"
size="sm"
className="sidebar-footer-btn"
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
onClick={() => setCollapsed((c) => !c)}
>
{collapsed ? (
<PanelLeft className="size-4" aria-hidden />
) : (
<PanelLeftClose className="size-4" aria-hidden />
)}
{!collapsed && (
<span className="sidebar-link-label">Collapse</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{collapsed ? "Expand" : "Collapse"}
</TooltipContent>
</Tooltip>
</div>
</aside>
{/* Mobile backdrop */}
{!collapsed && (
<div
role="button"
tabIndex={0}
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
onClick={() => setCollapsed(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
}}
aria-label="Close menu"
/>
)}
{/* Mobile floating button when sidebar is collapsed/hidden */}
{collapsed && (
<Button
type="button"
variant="ghost"
size="sm"
className="fixed bottom-3 left-3 z-40 md:hidden"
aria-label="Open menu"
onClick={() => setCollapsed(false)}
>
<PanelLeft className="size-4" aria-hidden />
</Button>
)}
<main className="app-main @container">
<Outlet />
</main>
</div>
);
}