mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
next interation global sidebar
This commit is contained in:
@@ -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,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
Reference in New Issue
Block a user