next interation global sidebar

This commit is contained in:
Oliver Kieselbach
2026-03-18 03:27:39 +01:00
parent dbbe411101
commit 4ee8f2553a
12 changed files with 677 additions and 475 deletions
@@ -14,7 +14,8 @@ import { VersionBlockSection } from "./components/VersionBlockSection";
import { SessionExportSection } from "./components/SessionExportSection";
import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection";
import { SessionReportsSection } from "./components/SessionReportsSection";
import { UnifiedSidebar, SidebarItem } from "../../components/UnifiedSidebar";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon } from "../../lib/sidebarIcons";
interface AdminConfiguration {
@@ -249,7 +250,7 @@ export default function AdminConfigurationPage() {
setError(null);
};
const adminSections: SidebarItem[] = [
const adminSections: PageSectionItem[] = [
{ id: "tenant-management", label: "Tenant Management", icon: <BuildingOfficeIcon /> },
{ id: "session-reports", label: "Session Reports", icon: <DocumentTextIcon /> },
{ id: "session-export", label: "Session Export", icon: <ArrowDownTrayIcon /> },
@@ -260,6 +261,8 @@ export default function AdminConfigurationPage() {
{ id: "version-block", label: "Version Block", icon: <NoSymbolIcon /> },
];
usePageSections(adminSections, "Admin", "scroll-spy");
if (!galacticAdminMode) {
return null;
}
@@ -267,7 +270,6 @@ export default function AdminConfigurationPage() {
return (
<ProtectedRoute requireGalacticAdmin>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<UnifiedSidebar items={adminSections} mode="scroll-spy" title="Admin">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
<div className="py-6 px-4 sm:px-6 lg:px-8">
@@ -407,7 +409,6 @@ export default function AdminConfigurationPage() {
</div>
</div>
</main>
</UnifiedSidebar>
</div>
</ProtectedRoute>
);
@@ -1,7 +1,8 @@
"use client";
import { useRouter } from "next/navigation";
import { UnifiedSidebar, SidebarItem } from "../../components/UnifiedSidebar";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { NAV_SECTIONS } from "./docsNavSections";
import {
KeyIcon,
@@ -27,7 +28,7 @@ const SECTION_ICONS: Record<string, React.ReactNode> = {
"analyze-rules": <SparklesIcon />,
};
const docsItems: SidebarItem[] = NAV_SECTIONS.map((s) => ({
const docsItems: PageSectionItem[] = NAV_SECTIONS.map((s) => ({
id: s.id,
label: s.label,
icon: SECTION_ICONS[s.id],
@@ -37,8 +38,10 @@ const docsItems: SidebarItem[] = NAV_SECTIONS.map((s) => ({
export function DocsSidebar({ children }: { children: React.ReactNode }) {
const router = useRouter();
usePageSections(docsItems, "Contents", "route");
return (
<UnifiedSidebar items={docsItems} mode="route" title="Contents">
<>
{/* Page header */}
<header className="bg-white shadow-sm border-b border-gray-200 sticky top-14 z-10">
<div className="px-4 sm:px-6 lg:px-8 py-4">
@@ -64,6 +67,6 @@ export function DocsSidebar({ children }: { children: React.ReactNode }) {
<p>Autopilot Monitor v1.0.0</p>
</div>
</div>
</UnifiedSidebar>
</>
);
}
+9 -3
View File
@@ -9,6 +9,8 @@ import { GalacticNotificationProvider } from "../contexts/GalacticNotificationCo
import { ThemeProvider } from "../contexts/ThemeContext";
import Navbar from "../components/Navbar";
import ScrollToTopButton from "../components/ScrollToTopButton";
import { SidebarProvider } from "../contexts/SidebarContext";
import { GlobalSidebar } from "../components/GlobalSidebar";
const inter = Inter({ subsets: ["latin"] });
@@ -117,9 +119,13 @@ export default function RootLayout({
<GalacticNotificationProvider>
<TenantProvider>
<SignalRProvider>
<Navbar />
{children}
<ScrollToTopButton />
<SidebarProvider>
<Navbar />
<GlobalSidebar>
{children}
</GlobalSidebar>
<ScrollToTopButton />
</SidebarProvider>
</SignalRProvider>
</TenantProvider>
</GalacticNotificationProvider>
@@ -22,7 +22,8 @@ import EventTimeline from "./components/EventTimeline";
import AnalysisResultsSection from "./components/AnalysisResultsSection";
import MarkFailedModal from "./components/MarkFailedModal";
import ReportSessionModal from "./components/ReportSessionModal";
import { UnifiedSidebar, SidebarItem } from "../../../components/UnifiedSidebar";
import { usePageSections } from "../../../hooks/usePageSections";
import { PageSectionItem } from "../../../contexts/SidebarContext";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon } from "../../../lib/sidebarIcons";
import DeviceDetailsCard from "./components/DeviceDetailsCard";
import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/lib/sessionExportUtils";
@@ -724,6 +725,22 @@ export default function SessionDetailPage() {
});
};
const sessionSections: PageSectionItem[] = useMemo(() => {
const s: PageSectionItem[] = [];
if (session) s.push({ id: "section-session-info", label: "Session Info", icon: <InformationCircleIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-device-details", label: "Device Details", icon: <ComputerDesktopIcon /> });
if (!isGatherRulesSession && session) s.push({ id: "section-enrollment-progress", label: "Enrollment Progress", icon: <PlayCircleIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-analysis", label: "Analysis", icon: <SparklesIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-performance", label: "Performance", icon: <ChartBarIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-scripts", label: "Script Executions", icon: <CodeBracketIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-downloads", label: "Downloads", icon: <ArrowDownTrayIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-install-progress", label: "Install Progress", icon: <ListBulletIcon /> });
s.push({ id: "section-event-timeline", label: "Event Timeline", icon: <ClockIcon /> });
return s;
}, [session, isGatherRulesSession]);
usePageSections(sessionSections, "Sections", "scroll-spy");
// Only show full-page loading spinner on the very first load (no data yet).
// Subsequent refreshes (SignalR, 30s poll) keep the existing UI visible.
if (loading && !session && events.length === 0) {
@@ -737,19 +754,6 @@ export default function SessionDetailPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<UnifiedSidebar items={(() => {
const s: SidebarItem[] = [];
if (session) s.push({ id: "section-session-info", label: "Session Info", icon: <InformationCircleIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-device-details", label: "Device Details", icon: <ComputerDesktopIcon /> });
if (!isGatherRulesSession && session) s.push({ id: "section-enrollment-progress", label: "Enrollment Progress", icon: <PlayCircleIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-analysis", label: "Analysis", icon: <SparklesIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-performance", label: "Performance", icon: <ChartBarIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-scripts", label: "Script Executions", icon: <CodeBracketIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-downloads", label: "Downloads", icon: <ArrowDownTrayIcon /> });
if (!isGatherRulesSession) s.push({ id: "section-install-progress", label: "Install Progress", icon: <ListBulletIcon /> });
s.push({ id: "section-event-timeline", label: "Event Timeline", icon: <ClockIcon /> });
return s;
})()} mode="scroll-spy" title="Sections">
{/* Header */}
<header className="bg-white shadow">
<div className="py-6 px-4 sm:px-6 lg:px-8 flex items-center justify-between">
@@ -980,7 +984,6 @@ export default function SessionDetailPage() {
/>
</main>
</UnifiedSidebar>
</div>
</ProtectedRoute>
);
@@ -9,7 +9,8 @@ import { useNotifications } from "../../contexts/NotificationContext";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import UnsavedChangesModal from "../../components/UnsavedChangesModal";
import { UnifiedSidebar, SidebarItem } from "../../components/UnifiedSidebar";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { ShieldCheckIcon, UsersIcon, CpuChipIcon, GearIcon, MagnifyingGlassIcon, LockClosedIcon, BellIcon, CloudArrowUpIcon, KeyIcon, CircleStackIcon, ArrowRightOnRectangleIcon } from "../../lib/sidebarIcons";
import AutopilotValidationSection from "./components/AutopilotValidationSection";
import AdminManagementSection from "./components/AdminManagementSection";
@@ -899,8 +900,8 @@ export default function SettingsPage() {
};
// Build sidebar sections based on user role
const settingsSections: SidebarItem[] = useMemo(() => {
const sections: SidebarItem[] = [];
const settingsSections: PageSectionItem[] = useMemo(() => {
const sections: PageSectionItem[] = [];
if (user?.isTenantAdmin) {
sections.push(
{ id: "autopilot-validation", label: "Autopilot Validation", icon: <ShieldCheckIcon /> },
@@ -925,6 +926,8 @@ export default function SettingsPage() {
return sections;
}, [user, config?.bootstrapTokenEnabled]);
usePageSections(settingsSections, "Settings", "scroll-spy");
// Access gate: Admin → full settings, Operator with bootstrap → bootstrap only, others → redirect
if (user && !user.isTenantAdmin && !user.isGalacticAdmin) {
if (user.role === 'Operator' && user.canManageBootstrapTokens) {
@@ -943,7 +946,6 @@ export default function SettingsPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<UnifiedSidebar items={settingsSections} mode="scroll-spy" title="Settings">
{/* Header */}
<header className="bg-white shadow">
<div className="py-6 px-4 sm:px-6 lg:px-8">
@@ -1219,7 +1221,6 @@ export default function SettingsPage() {
</div>
)}
</main>
</UnifiedSidebar>
</div>
<UnsavedChangesModal
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "../contexts/AuthContext";
import { useSidebar, PageSectionItem } from "../contexts/SidebarContext";
import { CollapseState } from "../hooks/useSidebarState";
import { DefaultSectionIcon } from "../lib/sidebarIcons";
import { DASHBOARD_ITEM, NAV_GROUPS, REGULAR_USER_ITEMS, NavItem, NavGroup } from "../lib/globalNavConfig";
// Sidebar pixel widths
export const SIDEBAR_PX: Record<CollapseState, number> = {
full: 224, // w-56
icons: 56, // w-14
hidden: 0,
};
const CHEVRON_W = 16;
const sidebarWidthClass: Record<CollapseState, string> = {
full: "w-56",
icons: "w-14",
hidden: "w-0",
};
export function GlobalSidebar({ children }: { children: ReactNode }) {
const {
collapseState, cycleCollapseState, setCollapseState,
pageSections, pageSectionsTitle, pageSectionsMode,
mobileDrawerOpen, setMobileDrawerOpen,
} = useSidebar();
const { isAuthenticated, user } = useAuth();
const pathname = usePathname();
// Track desktop breakpoint
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mql = window.matchMedia("(min-width: 768px)");
setIsDesktop(mql.matches);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
// Galactic admin mode from localStorage (same pattern as Navbar)
const [galacticAdminMode, setGalacticAdminMode] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const read = () => setGalacticAdminMode(localStorage.getItem("galacticAdminMode") === "true");
read();
window.addEventListener("localStorageChange", read);
window.addEventListener("storage", read);
return () => {
window.removeEventListener("localStorageChange", read);
window.removeEventListener("storage", read);
};
}, []);
// --- Scroll-spy for page sections ---
const [activeSectionId, setActiveSectionId] = useState("");
const observerRef = useRef<IntersectionObserver | null>(null);
const visibleSections = useRef<Set<string>>(new Set());
useEffect(() => {
if (pageSectionsMode !== "scroll-spy" || pageSections.length === 0) return;
observerRef.current?.disconnect();
visibleSections.current.clear();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleSections.current.add(entry.target.id);
} else {
visibleSections.current.delete(entry.target.id);
}
}
for (const item of pageSections) {
if (visibleSections.current.has(item.id)) {
setActiveSectionId(item.id);
break;
}
}
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 },
);
observerRef.current = observer;
for (const item of pageSections) {
const el = document.getElementById(item.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [pageSections, pageSectionsMode]);
// Route mode for page sections
useEffect(() => {
if (pageSectionsMode !== "route" || pageSections.length === 0) return;
const segment = pathname.split("/").pop() ?? "";
const match = pageSections.find((item) => item.id === segment);
if (match) setActiveSectionId(match.id);
}, [pathname, pageSections, pageSectionsMode]);
const scrollTo = useCallback((id: string) => {
const el = document.getElementById(id);
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - 90;
window.scrollTo({ top: y, behavior: "smooth" });
}
setActiveSectionId(id);
setMobileDrawerOpen(false);
}, [setMobileDrawerOpen]);
// --- Visibility filtering ---
const isTenantAdmin = user?.isTenantAdmin ?? false;
const isOperator = user?.role === "Operator";
const isAdminOrOperator = isTenantAdmin || isOperator;
const isGalacticAdmin = user?.isGalacticAdmin ?? false;
const isGroupVisible = (group: NavGroup): boolean => {
switch (group.visibility) {
case "all": return true;
case "adminOrOperator": return isAdminOrOperator;
case "galacticAdmin": return isGalacticAdmin && galacticAdminMode;
default: return false;
}
};
// Don't render sidebar on landing page or when not authenticated
if (!isAuthenticated || pathname === "/") {
return <>{children}</>;
}
// --- Render helpers ---
const renderIcon = (icon: ReactNode | undefined, sizeClass = "w-4 h-4") => {
if (icon) return <span className={`shrink-0 ${sizeClass}`}>{icon}</span>;
return <DefaultSectionIcon className={`shrink-0 ${sizeClass}`} />;
};
const isNavActive = (href: string) => {
if (href === "/dashboard") return pathname === "/dashboard" || pathname === "/";
return pathname.startsWith(href);
};
// Shared item classes
const itemClass = (active: boolean, galactic = false) => {
if (active) {
return galactic
? "bg-purple-50 text-purple-700 font-semibold dark:bg-purple-900/30 dark:text-purple-300"
: "bg-blue-50 text-blue-700 font-semibold dark:bg-blue-900/30 dark:text-blue-300";
}
return galactic
? "text-purple-600 hover:bg-purple-50 hover:text-purple-800 dark:text-purple-400 dark:hover:bg-purple-900/20 dark:hover:text-purple-300"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200";
};
// --- Render a global nav link ---
const renderGlobalItem = (item: NavItem, galactic = false) => {
const active = isNavActive(item.href);
const base = `flex items-center gap-2.5 rounded-md text-sm transition-colors ${itemClass(active, galactic)}`;
if (collapseState === "icons") {
return (
<li key={item.id}>
<Link
href={item.href}
className={`${base} px-2 py-2 justify-center relative group`}
title={item.label}
>
<span className="flex items-center justify-center w-full">
{renderIcon(item.icon, "w-4.5 h-4.5")}
</span>
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">
{item.label}
</span>
</Link>
</li>
);
}
return (
<li key={item.id}>
<Link href={item.href} className={`${base} px-3 py-1.5`}>
{renderIcon(item.icon, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</Link>
</li>
);
};
// --- Render a page section item ---
const renderSectionItem = (item: PageSectionItem) => {
const active = activeSectionId === item.id;
const base = `flex items-center gap-2.5 rounded-md text-sm transition-colors ${itemClass(active)}`;
if (collapseState === "icons") {
const inner = (
<span className="flex items-center justify-center w-full">
{renderIcon(item.icon, "w-4.5 h-4.5")}
</span>
);
if (pageSectionsMode === "route" && item.href) {
return (
<li key={item.id}>
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-2 py-2 justify-center relative group`} title={item.label}>
{inner}
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">{item.label}</span>
</Link>
</li>
);
}
return (
<li key={item.id}>
<button onClick={() => scrollTo(item.id)} className={`${base} w-full px-2 py-2 justify-center relative group`} title={item.label}>
{inner}
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">{item.label}</span>
</button>
</li>
);
}
// Full mode
if (pageSectionsMode === "route" && item.href) {
return (
<li key={item.id}>
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
{renderIcon(item.icon, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</Link>
</li>
);
}
return (
<li key={item.id}>
<button onClick={() => scrollTo(item.id)} className={`${base} w-full text-left px-3 py-1.5`}>
{renderIcon(item.icon, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</button>
</li>
);
};
// --- Build the nav content (shared between desktop and mobile) ---
const visibleGroups = NAV_GROUPS.filter(isGroupVisible);
const hasPageSections = pageSections.length > 0;
// Regular users see minimal nav
const isRegularUser = !isAdminOrOperator && !isGalacticAdmin;
const renderNavContent = (isMobile = false) => (
<>
{/* Dashboard */}
{renderGlobalItem(DASHBOARD_ITEM)}
{/* Regular user: only show Progress Portal */}
{isRegularUser && (
<div className={`${collapseState === "full" || isMobile ? "mt-3" : "mt-1"}`}>
{REGULAR_USER_ITEMS.map((item) => renderGlobalItem(item))}
</div>
)}
{/* Admin/Operator groups */}
{!isRegularUser && visibleGroups.map((group) => (
<div key={group.id} className={`${collapseState === "full" || isMobile ? "mt-4" : "mt-1"}`}>
{/* Group header (full mode only) */}
{(collapseState === "full" || isMobile) && (
<p className={`text-[11px] font-semibold uppercase tracking-wider mb-1 px-3 ${
group.style === "galactic"
? "text-purple-500 dark:text-purple-400"
: "text-gray-400 dark:text-gray-500"
}`}>
{group.label}
</p>
)}
{/* Divider in icons mode */}
{collapseState === "icons" && !isMobile && (
<hr className="mx-2 my-1.5 border-gray-200 dark:border-gray-700" />
)}
<ul className="space-y-0.5">
{group.items.map((item) => renderGlobalItem(item, group.style === "galactic"))}
</ul>
</div>
))}
{/* Divider + page sections */}
{hasPageSections && (
<>
{collapseState === "icons" && !isMobile ? (
<hr className="mx-2 my-2 border-blue-200 dark:border-blue-800" />
) : (
<div className="mt-4 mb-2 mx-3">
<hr className="border-gray-200 dark:border-gray-700" />
</div>
)}
{(collapseState === "full" || isMobile) && (
<p className="text-[11px] font-semibold uppercase tracking-wider mb-1 px-3 text-blue-500 dark:text-blue-400">
{pageSectionsTitle}
</p>
)}
<ul className="space-y-0.5">
{pageSections.map(renderSectionItem)}
</ul>
</>
)}
</>
);
return (
<>
{/* ===== Mobile: overlay ===== */}
{mobileDrawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setMobileDrawerOpen(false)}
/>
)}
{/* ===== Mobile: drawer ===== */}
<div
className={`fixed top-0 left-0 z-50 h-full w-56 bg-white shadow-xl transition-transform duration-200 md:hidden dark:bg-gray-800 ${
mobileDrawerOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 pt-5 pb-3 border-b border-gray-100 dark:border-gray-700">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider dark:text-gray-500">
Navigation
</p>
<button
onClick={() => setMobileDrawerOpen(false)}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-700"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-3 overflow-y-auto max-h-[calc(100%-4rem)]" style={{ scrollbarWidth: "none" }}>
{renderNavContent(true)}
</div>
</div>
{/* ===== Desktop: fixed sidebar ===== */}
<aside
className={`hidden md:flex fixed left-0 top-14 bottom-0 z-20 flex-col transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
>
{collapseState !== "hidden" && (
<nav className="flex flex-col h-full bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div className={`flex-1 overflow-y-auto overscroll-contain ${
collapseState === "full" ? "p-3" : "p-1.5"
}`} style={{ scrollbarWidth: "none" }}>
{renderNavContent()}
</div>
</nav>
)}
</aside>
{/* ===== Desktop: chevron toggle ===== */}
<button
onClick={collapseState === "hidden" ? () => setCollapseState("full") : cycleCollapseState}
className="hidden md:flex fixed z-30 top-1/2 -translate-y-1/2 items-center justify-center w-4 h-8 rounded-full bg-gray-100 text-gray-400 shadow-sm border border-gray-200 transition-all duration-200 hover:bg-gray-200 hover:text-gray-600 dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-300"
style={{ left: SIDEBAR_PX[collapseState] - CHEVRON_W / 2 }}
aria-label={collapseState === "hidden" ? "Show sidebar" : "Collapse sidebar"}
title={
collapseState === "full"
? "Show icons only"
: collapseState === "icons"
? "Hide sidebar"
: "Show sidebar"
}
>
<svg
className={`w-2.5 h-2.5 transition-transform duration-200 ${collapseState === "hidden" ? "" : "rotate-180"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* ===== Content — pushed right by sidebar width ===== */}
<div
style={{
marginLeft: isDesktop ? SIDEBAR_PX[collapseState] : 0,
transition: "margin-left 200ms ease-in-out",
}}
>
{children}
</div>
</>
);
}
@@ -19,7 +19,6 @@ export default function Navbar() {
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showNavMenu, setShowNavMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
@@ -43,7 +42,6 @@ export default function Navbar() {
const notificationRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
const settingsRef = useRef<HTMLDivElement>(null);
const navMenuRef = useRef<HTMLDivElement>(null);
const helpRef = useRef<HTMLDivElement>(null);
// Update localStorage when modes change and dispatch custom event
@@ -104,9 +102,6 @@ export default function Navbar() {
if (settingsRef.current && !settingsRef.current.contains(event.target as Node)) {
setShowSettings(false);
}
if (navMenuRef.current && !navMenuRef.current.contains(event.target as Node)) {
setShowNavMenu(false);
}
if (helpRef.current && !helpRef.current.contains(event.target as Node)) {
setShowHelp(false);
}
@@ -388,90 +383,6 @@ export default function Navbar() {
})()}
</div>
{/* Navigation Menu (Hamburger) — "Where do I go?" */}
<div className="relative" ref={navMenuRef}>
<button onClick={() => setShowNavMenu(!showNavMenu)} className="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Navigation">
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{showNavMenu && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[32rem] overflow-y-auto">
<div className="p-3">
{/* MONITORING */}
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-1.5">Monitoring</p>
<Link href="/fleet-health" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span className="text-sm text-gray-700">Fleet Health</span>
</Link>
<Link href="/usage-metrics" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<span className="text-sm text-gray-700">Usage Metrics</span>
</Link>
<Link href="/geographic-performance" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-gray-700">Geographic Performance</span>
</Link>
<Link href="/progress" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span className="text-sm text-gray-700">Progress Portal</span>
</Link>
{/* OPERATIONS */}
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mt-3 mb-1.5">Operations</p>
<Link href="/audit" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm text-gray-700">Audit Log</span>
</Link>
<Link href="/health-check" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-gray-700">System Health</span>
</Link>
{/* GALACTIC ADMIN — only visible when galacticAdminMode is active */}
{galacticAdminMode && (
<>
<p className="text-[11px] font-semibold uppercase tracking-wider text-purple-400 mt-3 mb-1.5">Galactic Admin</p>
<Link href="/platform-usage-metrics" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-purple-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-purple-700">Platform Usage Metrics</span>
</Link>
<Link href="/platform-metrics" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-purple-50 transition-colors" onClick={() => setShowNavMenu(false)}>
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span className="text-sm text-purple-700">Platform Metrics</span>
</Link>
</>
)}
</div>
</div>
)}
</div>
{/* Settings Menu (Gear) — "How do I configure?" */}
<div className="relative" ref={settingsRef}>
<button onClick={() => setShowSettings(!showSettings)} className="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Settings">
@@ -1,354 +0,0 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSidebarState, CollapseState } from "../hooks/useSidebarState";
import { DefaultSectionIcon } from "../lib/sidebarIcons";
export interface SidebarItem {
id: string;
label: string;
icon?: React.ReactNode;
href?: string;
}
interface UnifiedSidebarProps {
items: SidebarItem[];
mode: "scroll-spy" | "route";
title?: string;
children: React.ReactNode;
}
// Sidebar pixel widths
const SIDEBAR_PX: Record<CollapseState, number> = {
full: 224, // w-56
icons: 56, // w-14
hidden: 0,
};
const CHEVRON_W = 16; // w-4, visual only — does NOT affect content margin
export function UnifiedSidebar({
items,
mode,
title = "Contents",
children,
}: UnifiedSidebarProps) {
const { collapseState, cycleCollapseState, setCollapseState } = useSidebarState();
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const visibleSections = useRef<Set<string>>(new Set());
const pathname = usePathname();
// Track desktop breakpoint (md = 768px) for margin calculation
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mql = window.matchMedia("(min-width: 768px)");
setIsDesktop(mql.matches);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
// --- Scroll-spy mode: IntersectionObserver ---
useEffect(() => {
if (mode !== "scroll-spy") return;
observerRef.current?.disconnect();
visibleSections.current.clear();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleSections.current.add(entry.target.id);
} else {
visibleSections.current.delete(entry.target.id);
}
}
for (const item of items) {
if (visibleSections.current.has(item.id)) {
setActiveId(item.id);
break;
}
}
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 },
);
observerRef.current = observer;
for (const item of items) {
const el = document.getElementById(item.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [items, mode]);
// --- Route mode: track active from pathname ---
useEffect(() => {
if (mode !== "route") return;
const segment = pathname.split("/").pop() ?? "";
const match = items.find((item) => item.id === segment);
if (match) setActiveId(match.id);
}, [pathname, items, mode]);
const scrollTo = useCallback((id: string) => {
const el = document.getElementById(id);
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - 90;
window.scrollTo({ top: y, behavior: "smooth" });
}
setActiveId(id);
setMobileDrawerOpen(false);
}, []);
const handleItemClick = useCallback(
(item: SidebarItem) => {
if (mode === "scroll-spy") {
scrollTo(item.id);
} else {
setMobileDrawerOpen(false);
}
},
[mode, scrollTo],
);
const activeLabel = items.find((i) => i.id === activeId)?.label ?? title;
const sidebarWidthClass: Record<CollapseState, string> = {
full: "w-56",
icons: "w-14",
hidden: "w-0",
};
const renderIcon = (item: SidebarItem, sizeClass = "w-5 h-5") => {
if (item.icon) {
return <span className={`shrink-0 ${sizeClass}`}>{item.icon}</span>;
}
return <DefaultSectionIcon className={`shrink-0 ${sizeClass}`} />;
};
const renderNavItem = (item: SidebarItem) => {
const isActive = activeId === item.id;
const baseClass = `flex items-center gap-2.5 rounded-md text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-semibold dark:bg-blue-900/30 dark:text-blue-300"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
}`;
// Icons-only mode
if (collapseState === "icons") {
const content = (
<span className="flex items-center justify-center w-full">
{renderIcon(item, "w-4.5 h-4.5")}
</span>
);
if (mode === "route" && item.href) {
return (
<li key={item.id}>
<Link
href={item.href}
onClick={() => handleItemClick(item)}
className={`${baseClass} px-2 py-2 justify-center relative group`}
title={item.label}
>
{content}
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">
{item.label}
</span>
</Link>
</li>
);
}
return (
<li key={item.id}>
<button
onClick={() => handleItemClick(item)}
className={`${baseClass} w-full px-2 py-2 justify-center relative group`}
title={item.label}
>
{content}
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">
{item.label}
</span>
</button>
</li>
);
}
// Full mode: icon + label
if (mode === "route" && item.href) {
return (
<li key={item.id}>
<Link
href={item.href}
onClick={() => handleItemClick(item)}
className={`${baseClass} px-3 py-2`}
>
{renderIcon(item, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</Link>
</li>
);
}
return (
<li key={item.id}>
<button
onClick={() => handleItemClick(item)}
className={`${baseClass} w-full text-left px-3 py-2`}
>
{renderIcon(item, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</button>
</li>
);
};
const navItems = items.map(renderNavItem);
return (
<>
{/* ===== Mobile: overlay ===== */}
{mobileDrawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setMobileDrawerOpen(false)}
/>
)}
{/* ===== Mobile: drawer ===== */}
<div
className={`fixed top-0 left-0 z-50 h-full w-56 bg-white shadow-xl transition-transform duration-200 md:hidden dark:bg-gray-800 ${
mobileDrawerOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 pt-5 pb-3 border-b border-gray-100 dark:border-gray-700">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider dark:text-gray-500">
{title}
</p>
<button
onClick={() => setMobileDrawerOpen(false)}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-700"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<ul className="p-3 space-y-0.5 overflow-y-auto max-h-[calc(100%-4rem)]">
{items.map((item) => {
const isActive = activeId === item.id;
const cls = `flex items-center gap-2.5 w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? "bg-blue-50 text-blue-700 font-semibold dark:bg-blue-900/30 dark:text-blue-300"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
}`;
if (mode === "route" && item.href) {
return (
<li key={item.id}>
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={cls}>
{renderIcon(item, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</Link>
</li>
);
}
return (
<li key={item.id}>
<button onClick={() => scrollTo(item.id)} className={cls}>
{renderIcon(item, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</button>
</li>
);
})}
</ul>
</div>
{/* ===== Mobile: toggle bar ===== */}
<div className="md:hidden mb-4">
<button
onClick={() => setMobileDrawerOpen(true)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white shadow-sm text-sm text-gray-600 hover:bg-gray-50 transition-colors dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
<span className="font-medium text-gray-700 dark:text-gray-300">{activeLabel}</span>
<svg className="w-3.5 h-3.5 text-gray-400 ml-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* ===== Desktop: fixed sidebar pinned left, below navbar ===== */}
<aside
className={`hidden md:flex fixed left-0 top-14 bottom-0 z-20 flex-col transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
>
{collapseState !== "hidden" && (
<nav className="flex flex-col h-full bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
{/* Title (full mode only) */}
{collapseState === "full" && (
<p className="shrink-0 text-xs font-semibold text-gray-400 uppercase tracking-wider px-4 pt-4 pb-1 dark:text-gray-500">
{title}
</p>
)}
{/* Nav items */}
<ul className={`flex-1 space-y-0.5 overflow-y-auto overscroll-contain ${
collapseState === "full" ? "p-3 pt-2" : "p-1.5"
}`}
style={{ scrollbarWidth: "none" }}
>
{navItems}
</ul>
</nav>
)}
</aside>
{/* ===== Desktop: chevron toggle — vertically centered at sidebar edge ===== */}
<button
onClick={collapseState === "hidden" ? () => setCollapseState("full") : cycleCollapseState}
className="hidden md:flex fixed z-30 top-1/2 -translate-y-1/2 items-center justify-center w-4 h-8 rounded-full bg-gray-100 text-gray-400 shadow-sm border border-gray-200 transition-all duration-200 hover:bg-gray-200 hover:text-gray-600 dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-300"
style={{ left: SIDEBAR_PX[collapseState] - CHEVRON_W / 2 }}
aria-label={collapseState === "hidden" ? "Show sidebar" : "Collapse sidebar"}
title={
collapseState === "full"
? "Show icons only"
: collapseState === "icons"
? "Hide sidebar"
: "Show sidebar"
}
>
<svg
className={`w-2.5 h-2.5 transition-transform duration-200 ${collapseState === "hidden" ? "" : "rotate-180"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* ===== Content — pushed right by sidebar width on desktop ===== */}
<div
style={{
marginLeft: isDesktop ? SIDEBAR_PX[collapseState] : 0,
transition: "margin-left 200ms ease-in-out",
}}
>
{children}
</div>
</>
);
}
@@ -0,0 +1,80 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import { useSidebarState, CollapseState } from "../hooks/useSidebarState";
export interface PageSectionItem {
id: string;
label: string;
icon?: ReactNode;
href?: string;
}
interface SidebarContextValue {
// Collapse state (shared across app)
collapseState: CollapseState;
setCollapseState: (state: CollapseState) => void;
cycleCollapseState: () => void;
// Page sections (set by individual pages)
pageSections: PageSectionItem[];
pageSectionsTitle: string;
pageSectionsMode: "scroll-spy" | "route";
setPageSections: (items: PageSectionItem[], title: string, mode: "scroll-spy" | "route") => void;
clearPageSections: () => void;
// Mobile drawer
mobileDrawerOpen: boolean;
setMobileDrawerOpen: (open: boolean) => void;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
export function SidebarProvider({ children }: { children: ReactNode }) {
const { collapseState, setCollapseState, cycleCollapseState } = useSidebarState();
const [pageSections, setPageSectionsState] = useState<PageSectionItem[]>([]);
const [pageSectionsTitle, setPageSectionsTitle] = useState("");
const [pageSectionsMode, setPageSectionsMode] = useState<"scroll-spy" | "route">("scroll-spy");
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const setPageSections = useCallback(
(items: PageSectionItem[], title: string, mode: "scroll-spy" | "route") => {
setPageSectionsState(items);
setPageSectionsTitle(title);
setPageSectionsMode(mode);
},
[],
);
const clearPageSections = useCallback(() => {
setPageSectionsState([]);
setPageSectionsTitle("");
setPageSectionsMode("scroll-spy");
}, []);
return (
<SidebarContext.Provider
value={{
collapseState,
setCollapseState,
cycleCollapseState,
pageSections,
pageSectionsTitle,
pageSectionsMode,
setPageSections,
clearPageSections,
mobileDrawerOpen,
setMobileDrawerOpen,
}}
>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const ctx = useContext(SidebarContext);
if (!ctx) throw new Error("useSidebar must be used within SidebarProvider");
return ctx;
}
@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { useSidebar, PageSectionItem } from "../contexts/SidebarContext";
/**
* Register page-specific sections in the global sidebar.
* Sections appear below the global nav with a divider.
* Automatically clears on unmount.
*/
export function usePageSections(
items: PageSectionItem[],
title: string,
mode: "scroll-spy" | "route" = "scroll-spy",
) {
const { setPageSections, clearPageSections } = useSidebar();
useEffect(() => {
if (items.length > 0) {
setPageSections(items, title, mode);
}
return () => clearPageSections();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, title, mode]);
}
@@ -0,0 +1,124 @@
import {
ChartBarIcon,
GearIcon,
ComputerDesktopIcon,
DocumentTextIcon,
ShieldCheckIcon,
} from "./sidebarIcons";
// --- New icons for global nav (added inline here, monochrome outline style) ---
function HomeIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
);
}
function TrendingUpIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
</svg>
);
}
function GlobeAltIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
);
}
function MonitorIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
);
}
function ClipboardDocumentIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25" />
</svg>
);
}
function HeartIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
);
}
// --- Types ---
export interface NavItem {
id: string;
label: string;
href: string;
icon: React.ReactNode;
}
export interface NavGroup {
id: string;
label: string;
items: NavItem[];
visibility: "all" | "adminOrOperator" | "galacticAdmin";
style?: "galactic";
}
// --- Dashboard item (standalone, above all groups) ---
export const DASHBOARD_ITEM: NavItem = {
id: "dashboard",
label: "Dashboard",
href: "/dashboard",
icon: <HomeIcon />,
};
// --- Global navigation groups ---
export const NAV_GROUPS: NavGroup[] = [
{
id: "monitoring",
label: "Monitoring",
visibility: "adminOrOperator",
items: [
{ id: "fleet-health", label: "Fleet Health", href: "/fleet-health", icon: <ChartBarIcon /> },
{ id: "usage-metrics", label: "Usage Metrics", href: "/usage-metrics", icon: <TrendingUpIcon /> },
{ id: "geographic-performance", label: "Geographic Perf.", href: "/geographic-performance", icon: <GlobeAltIcon /> },
{ id: "progress", label: "Progress Portal", href: "/progress", icon: <MonitorIcon /> },
],
},
{
id: "operations",
label: "Operations",
visibility: "adminOrOperator",
items: [
{ id: "audit", label: "Audit Log", href: "/audit", icon: <ClipboardDocumentIcon /> },
{ id: "health-check", label: "System Health", href: "/health-check", icon: <HeartIcon /> },
],
},
{
id: "galactic-admin",
label: "Galactic Admin",
visibility: "galacticAdmin",
style: "galactic",
items: [
{ id: "platform-usage-metrics", label: "Platform Usage", href: "/platform-usage-metrics", icon: <GlobeAltIcon /> },
{ id: "platform-metrics", label: "Platform Metrics", href: "/platform-metrics", icon: <ChartBarIcon /> },
],
},
];
// --- Regular user nav (minimal) ---
export const REGULAR_USER_ITEMS: NavItem[] = [
{ id: "progress", label: "Progress Portal", href: "/progress", icon: <MonitorIcon /> },
];
File diff suppressed because one or more lines are too long