import { useEffect, useRef, useState } from "react"; import { NavLink, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { LayoutDashboard, Boxes, Network, Image, Database, FolderTree, LayoutTemplate, ScrollText, Settings, Moon, Sun, LogOut, ShieldOff, Menu, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/store/auth"; import { useThemeStore } from "@/store/theme"; import { agentsApi } from "@/api/agents"; import { VersionBadge } from "./VersionBadge"; type NavItem = { to: string; label: string; icon: typeof LayoutDashboard; end?: boolean; /** Hidden for non-admins — the matching API routes require the admin role. */ adminOnly?: boolean; }; export const NAV_ITEMS: NavItem[] = [ { to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, { to: "/stacks", label: "Stacks", icon: Boxes }, { to: "/networks", label: "Networks", icon: Network }, { to: "/images", label: "Images", icon: Image }, { to: "/volumes", label: "Volumes", icon: Database }, { to: "/files", label: "Files", icon: FolderTree, adminOnly: true }, { to: "/templates", label: "Templates", icon: LayoutTemplate }, { to: "/audit", label: "Audit", icon: ScrollText, adminOnly: true }, { to: "/settings", label: "Settings", icon: Settings }, ]; function LogoMark() { return ( {/* Stacked-layers glyph */} ); } function initials(name: string | undefined): string { if (!name) return "?"; return name.slice(0, 2).toUpperCase(); } export function TopNav() { const [drawerOpen, setDrawerOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); const navigate = useNavigate(); const user = useAuthStore((s) => s.user); const navItems = NAV_ITEMS.filter((i) => !i.adminOnly || user?.role === "admin"); const logout = useAuthStore((s) => s.logout); const signOutEverywhere = useAuthStore((s) => s.signOutEverywhere); const { theme, toggle } = useThemeStore(); const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 30000, }); const agentCount = agents.data?.length ?? 0; const agentsOnline = agents.data?.filter((a) => a.status === "online").length ?? 0; // Close avatar menu on outside click. useEffect(() => { if (!menuOpen) return; const onClick = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setMenuOpen(false); } }; document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); }, [menuOpen]); return ( <> {/* Hamburger (mobile / narrow) */} setDrawerOpen(true)} className="rounded-lg p-2 text-sp-text-2 hover:bg-sp-surface lg:hidden" title="Open menu" aria-label="Open navigation menu" > {/* Logo */} StackPilot {/* Pill nav */} {navItems.map(({ to, label, end }) => ( {({ isActive }) => ( {label} )} ))} {/* Right cluster */} {agentCount > 0 && ( navigate("/settings")} className="hidden items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 text-xs font-medium text-sp-text-2 hover:text-sp-text-1 sm:flex" title={`${agentsOnline}/${agentCount} remote hosts online`} > {agentsOnline}/{agentCount} )} {theme === "dark" ? : } setMenuOpen((v) => !v)} className="flex h-9 w-9 items-center justify-center rounded-full bg-sp-pill text-xs font-bold text-sp-pill-text" title={user?.username} aria-haspopup="menu" aria-expanded={menuOpen} > {initials(user?.username)} {menuOpen && ( {user?.username ?? "—"} {user?.role === "admin" && admin} void logout()} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1" > Sign out void signOutEverywhere()} title="Revokes every token this account holds, on every device" className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1" > Sign out everywhere )} {/* Off-canvas drawer (narrow screens) */} {drawerOpen && ( setDrawerOpen(false)} /> )} > ); }
{user?.username ?? "—"}
admin