added a unified sidebar

This commit is contained in:
Oliver Kieselbach
2026-03-18 01:59:29 +01:00
parent 65623bf812
commit c4ce59df2f
10 changed files with 715 additions and 499 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 { ScrollSpySidebar, SidebarSection } from "../../components/ScrollSpySidebar";
import { UnifiedSidebar, SidebarItem } from "../../components/UnifiedSidebar";
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon } from "../../lib/sidebarIcons";
interface AdminConfiguration {
partitionKey: string;
@@ -248,15 +249,15 @@ export default function AdminConfigurationPage() {
setError(null);
};
const adminSections: SidebarSection[] = [
{ id: "tenant-management", label: "Tenant Management" },
{ id: "session-reports", label: "Session Reports" },
{ id: "session-export", label: "Session Export" },
{ id: "global-settings", label: "Global Settings" },
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths" },
{ id: "maintenance", label: "Maintenance" },
{ id: "device-block", label: "Device Block" },
{ id: "version-block", label: "Version Block" },
const adminSections: SidebarItem[] = [
{ id: "tenant-management", label: "Tenant Management", icon: <BuildingOfficeIcon /> },
{ id: "session-reports", label: "Session Reports", icon: <DocumentTextIcon /> },
{ id: "session-export", label: "Session Export", icon: <ArrowDownTrayIcon /> },
{ id: "global-settings", label: "Global Settings", icon: <GearIcon /> },
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", icon: <FolderIcon /> },
{ id: "maintenance", label: "Maintenance", icon: <WrenchScrewdriverIcon /> },
{ id: "device-block", label: "Device Block", icon: <NoSymbolIcon /> },
{ id: "version-block", label: "Version Block", icon: <NoSymbolIcon /> },
];
if (!galacticAdminMode) {
@@ -288,7 +289,7 @@ export default function AdminConfigurationPage() {
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<ScrollSpySidebar sections={adminSections} title="Admin">
<UnifiedSidebar items={adminSections} mode="scroll-spy" title="Admin">
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
@@ -405,7 +406,7 @@ export default function AdminConfigurationPage() {
/>
</div>
</div>
</ScrollSpySidebar>
</UnifiedSidebar>
</main>
</div>
</ProtectedRoute>
@@ -1,118 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { UnifiedSidebar, SidebarItem } from "../../components/UnifiedSidebar";
import { NAV_SECTIONS } from "./docsNavSections";
import {
KeyIcon,
BookOpenIcon,
InformationCircleIcon,
WrenchScrewdriverIcon,
ComputerDesktopIcon,
RocketLaunchIcon,
GearIcon,
ListBulletIcon,
SparklesIcon,
} from "../../lib/sidebarIcons";
const SECTION_ICONS: Record<string, React.ReactNode> = {
"private-preview": <KeyIcon />,
"overview": <BookOpenIcon />,
"general": <InformationCircleIcon />,
"setup": <WrenchScrewdriverIcon />,
"agent": <ComputerDesktopIcon />,
"agent-setup": <RocketLaunchIcon />,
"settings": <GearIcon />,
"gather-rules": <ListBulletIcon />,
"analyze-rules": <SparklesIcon />,
};
const docsItems: SidebarItem[] = NAV_SECTIONS.map((s) => ({
id: s.id,
label: s.label,
icon: SECTION_ICONS[s.id],
href: `/docs/${s.id}`,
}));
export function DocsSidebar({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const activeSection = pathname.split("/").pop() ?? "overview";
const activeLabel = NAV_SECTIONS.find((s) => s.id === activeSection)?.label ?? "Contents";
return (
<>
{/* Mobile sidebar overlay */}
{mobileSidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{/* Mobile sidebar drawer */}
<div
className={`fixed top-0 left-0 z-50 h-full w-56 bg-white shadow-xl transition-transform duration-200 md:hidden ${
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 pt-5 pb-3 border-b border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Contents</p>
<button
onClick={() => setMobileSidebarOpen(false)}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<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">
{NAV_SECTIONS.map((s) => (
<li key={s.id}>
<Link
href={`/docs/${s.id}`}
onClick={() => setMobileSidebarOpen(false)}
className={`block w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
activeSection === s.id
? "bg-blue-50 text-blue-700 font-semibold"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`}
>
{s.label}
</Link>
</li>
))}
</ul>
</div>
{/* Two-column layout */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex gap-8 items-start">
{/* Desktop sidebar */}
<aside className="w-52 shrink-0 hidden md:block">
<nav className="sticky top-24 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 px-1">
Contents
</p>
<ul className="space-y-0.5">
{NAV_SECTIONS.map((s) => (
<li key={s.id}>
<Link
href={`/docs/${s.id}`}
className={`block px-3 py-2 rounded-md text-sm transition-colors ${
activeSection === s.id
? "bg-blue-50 text-blue-700 font-semibold"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`}
>
{s.label}
</Link>
</li>
))}
</ul>
</nav>
</aside>
{/* Content */}
<main className="flex-1 min-w-0 space-y-8">
{/* Mobile: contents toggle bar */}
<div className="md:hidden">
<button
onClick={() => setMobileSidebarOpen(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"
>
<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">{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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<UnifiedSidebar items={docsItems} mode="route" title="Contents">
<div className="space-y-8">
{children}
<div className="text-center text-sm text-gray-500 pb-4">
<p>Autopilot Monitor v1.0.0</p>
</div>
</main>
</div>
</>
</div>
</UnifiedSidebar>
</div>
);
}
@@ -1,156 +0,0 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
export interface SectionEntry {
id: string;
label: string;
}
interface SectionNavProps {
sections: SectionEntry[];
}
export default function SectionNav({ sections }: SectionNavProps) {
const [open, setOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Track active section via IntersectionObserver
useEffect(() => {
if (sections.length === 0) return;
const observers: IntersectionObserver[] = [];
const visibleSections = new Map<string, number>();
sections.forEach((section) => {
const el = document.getElementById(section.id);
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
visibleSections.set(section.id, entry.intersectionRatio);
} else {
visibleSections.delete(section.id);
}
// Pick topmost visible section
let topId: string | null = null;
let topY = Infinity;
visibleSections.forEach((_, id) => {
const rect = document.getElementById(id)?.getBoundingClientRect();
if (rect && rect.top < topY) {
topY = rect.top;
topId = id;
}
});
if (topId) setActiveId(topId);
});
},
{ rootMargin: "-10% 0px -60% 0px", threshold: 0 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, [sections]);
// Close on click outside
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const scrollTo = useCallback(
(id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
setOpen(false);
},
[]
);
if (sections.length === 0) return null;
return (
<div
ref={panelRef}
className="fixed left-0 top-1/2 z-40 -translate-y-1/2"
>
{/* Collapsed tab */}
{!open && (
<button
onClick={() => setOpen(true)}
className="flex h-16 w-4 items-center justify-center rounded-r-md bg-gray-300/60 text-gray-500 transition-colors hover:bg-gray-300 hover:text-gray-700 dark:bg-gray-700/60 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
aria-label="Open section navigation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3 w-3"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Expanded panel */}
{open && (
<div className="flex animate-slide-in-left flex-col rounded-r-lg bg-white/95 py-3 pl-3 pr-4 shadow-lg backdrop-blur dark:bg-gray-800/95">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Sections
</span>
<button
onClick={() => setOpen(false)}
className="ml-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Close section navigation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3 w-3"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
<nav className="flex flex-col gap-0.5">
{sections.map((s) => (
<button
key={s.id}
onClick={() => scrollTo(s.id)}
className={`whitespace-nowrap rounded px-2 py-1.5 text-left text-sm transition-colors ${
activeId === s.id
? "bg-blue-50 font-medium text-blue-700 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"
}`}
>
{s.label}
</button>
))}
</nav>
</div>
)}
</div>
);
}
@@ -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 SectionNav, { SectionEntry } from "./components/SectionNav";
import { UnifiedSidebar, SidebarItem } from "../../../components/UnifiedSidebar";
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";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
@@ -813,20 +814,19 @@ export default function SessionDetailPage() {
</div>
</header>
<SectionNav sections={(() => {
const s: SectionEntry[] = [];
if (session) s.push({ id: "section-session-info", label: "Session Info" });
if (!isGatherRulesSession) s.push({ id: "section-device-details", label: "Device Details" });
if (!isGatherRulesSession && session) s.push({ id: "section-enrollment-progress", label: "Enrollment Progress" });
if (!isGatherRulesSession) s.push({ id: "section-analysis", label: "Analysis" });
if (!isGatherRulesSession) s.push({ id: "section-performance", label: "Performance" });
if (!isGatherRulesSession) s.push({ id: "section-scripts", label: "Script Executions" });
if (!isGatherRulesSession) s.push({ id: "section-downloads", label: "Downloads" });
if (!isGatherRulesSession) s.push({ id: "section-install-progress", label: "Install Progress" });
s.push({ id: "section-event-timeline", label: "Event Timeline" });
<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">
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Session Info Card */}
@@ -980,6 +980,7 @@ 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 { ScrollSpySidebar, SidebarSection } from "../../components/ScrollSpySidebar";
import { UnifiedSidebar, SidebarItem } from "../../components/UnifiedSidebar";
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";
import HardwareWhitelistSection from "./components/HardwareWhitelistSection";
@@ -898,27 +899,27 @@ export default function SettingsPage() {
};
// Build sidebar sections based on user role
const settingsSections: SidebarSection[] = useMemo(() => {
const sections: SidebarSection[] = [];
const settingsSections: SidebarItem[] = useMemo(() => {
const sections: SidebarItem[] = [];
if (user?.isTenantAdmin) {
sections.push(
{ id: "autopilot-validation", label: "Autopilot Validation" },
{ id: "admin-management", label: "Admin Management" },
{ id: "hardware-whitelist", label: "Hardware Whitelist" },
{ id: "agent-settings", label: "Agent Settings" },
{ id: "agent-analyzers", label: "Agent Analyzers" },
{ id: "unrestricted-mode", label: "Unrestricted Mode" },
{ id: "notifications", label: "Notifications" },
{ id: "diagnostics", label: "Diagnostics" },
{ id: "autopilot-validation", label: "Autopilot Validation", icon: <ShieldCheckIcon /> },
{ id: "admin-management", label: "Admin Management", icon: <UsersIcon /> },
{ id: "hardware-whitelist", label: "Hardware Whitelist", icon: <CpuChipIcon /> },
{ id: "agent-settings", label: "Agent Settings", icon: <GearIcon /> },
{ id: "agent-analyzers", label: "Agent Analyzers", icon: <MagnifyingGlassIcon /> },
{ id: "unrestricted-mode", label: "Unrestricted Mode", icon: <LockClosedIcon /> },
{ id: "notifications", label: "Notifications", icon: <BellIcon /> },
{ id: "diagnostics", label: "Diagnostics", icon: <CloudArrowUpIcon /> },
);
}
if (config?.bootstrapTokenEnabled && (user?.isTenantAdmin || user?.canManageBootstrapTokens)) {
sections.push({ id: "bootstrap-sessions", label: "Bootstrap Sessions" });
sections.push({ id: "bootstrap-sessions", label: "Bootstrap Sessions", icon: <KeyIcon /> });
}
if (user?.isTenantAdmin) {
sections.push(
{ id: "data-management", label: "Data Management" },
{ id: "offboarding", label: "Offboarding" },
{ id: "data-management", label: "Data Management", icon: <CircleStackIcon /> },
{ id: "offboarding", label: "Offboarding", icon: <ArrowRightOnRectangleIcon /> },
);
}
return sections;
@@ -988,7 +989,7 @@ export default function SettingsPage() {
<p className="mt-4 text-gray-600">Loading configuration...</p>
</div>
) : (
<ScrollSpySidebar sections={settingsSections} title="Settings">
<UnifiedSidebar items={settingsSections} mode="scroll-spy" title="Settings">
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
@@ -1216,7 +1217,7 @@ export default function SettingsPage() {
)}
</div>
</ScrollSpySidebar>
</UnifiedSidebar>
)}
</main>
</div>
@@ -1,194 +0,0 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
export interface SidebarSection {
id: string;
label: string;
}
interface ScrollSpySidebarProps {
sections: SidebarSection[];
title?: string;
children: React.ReactNode;
}
export function ScrollSpySidebar({
sections,
title = "Contents",
children,
}: ScrollSpySidebarProps) {
const [activeId, setActiveId] = useState(sections[0]?.id ?? "");
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
// Track which sections are currently intersecting
const visibleSections = useRef<Set<string>>(new Set());
const activeLabel =
sections.find((s) => s.id === activeId)?.label ?? title;
// Set up IntersectionObserver for scroll-spy
useEffect(() => {
observerRef.current?.disconnect();
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);
}
}
// Pick the first visible section in DOM order
for (const s of sections) {
if (visibleSections.current.has(s.id)) {
setActiveId(s.id);
break;
}
}
},
{
rootMargin: "-80px 0px -60% 0px",
threshold: 0,
},
);
observerRef.current = observer;
// Observe all section elements
for (const s of sections) {
const el = document.getElementById(s.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [sections]);
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);
setMobileSidebarOpen(false);
},
[],
);
const navItems = sections.map((s) => (
<li key={s.id}>
<button
onClick={() => scrollTo(s.id)}
className={`block w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
activeId === s.id
? "bg-blue-50 text-blue-700 font-semibold"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`}
>
{s.label}
</button>
</li>
));
return (
<>
{/* Mobile sidebar overlay */}
{mobileSidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{/* Mobile sidebar drawer */}
<div
className={`fixed top-0 left-0 z-50 h-full w-56 bg-white shadow-xl transition-transform duration-200 md:hidden ${
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 pt-5 pb-3 border-b border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
{title}
</p>
<button
onClick={() => setMobileSidebarOpen(false)}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<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)]">
{navItems}
</ul>
</div>
{/* Mobile: contents toggle bar */}
<div className="md:hidden mb-4">
<button
onClick={() => setMobileSidebarOpen(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"
>
<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">{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>
{/* Two-column layout */}
<div className="flex gap-8 items-start">
{/* Desktop sidebar */}
<aside className="w-52 shrink-0 hidden md:block">
<nav className="sticky top-24 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 px-1">
{title}
</p>
<ul className="space-y-0.5">{navItems}</ul>
</nav>
</aside>
{/* Content */}
<div className="flex-1 min-w-0">{children}</div>
</div>
</>
);
}
@@ -0,0 +1,334 @@
"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;
}
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();
// --- 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);
}
}
// Pick first visible section in DOM order
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;
// Width classes for each collapse state
const sidebarWidthClass: Record<CollapseState, string> = {
full: "w-52",
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: centered icon with tooltip
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>
);
};
// Shared nav items for both desktop and mobile
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)]">
{/* In mobile drawer, always show full items (icon + label) */}
{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: two-column flex layout ===== */}
<div className="flex gap-0 items-start">
{/* Desktop sidebar */}
<aside
className={`shrink-0 hidden md:block transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
>
{collapseState !== "hidden" && (
<nav className="sticky top-24 bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
{/* Title (full mode only) */}
{collapseState === "full" && (
<p className="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={`space-y-0.5 overflow-y-auto max-h-[calc(100vh-8rem)] ${
collapseState === "full" ? "p-3 pt-2" : "p-1.5"
}`}>
{navItems}
</ul>
</nav>
)}
</aside>
{/* Edge-chevron toggle (desktop only) */}
<div className="hidden md:flex items-start shrink-0">
<button
onClick={collapseState === "hidden" ? () => setCollapseState("full") : cycleCollapseState}
className="sticky top-24 mt-1 flex h-8 w-4 items-center justify-center rounded-r-md bg-gray-200/60 text-gray-400 transition-colors hover:bg-gray-300 hover:text-gray-600 dark:bg-gray-700/60 dark:text-gray-500 dark:hover:bg-gray-600 dark:hover:text-gray-300"
aria-label={collapseState === "hidden" ? "Show sidebar" : "Collapse sidebar"}
title={
collapseState === "full"
? "Show icons only"
: collapseState === "icons"
? "Hide sidebar"
: "Show sidebar"
}
>
<svg
className={`w-3 h-3 transition-transform duration-200 ${collapseState === "hidden" ? "" : "rotate-180"}`}
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>
{/* Content */}
<div className={`flex-1 min-w-0 ${collapseState !== "hidden" ? "md:ml-2" : ""}`}>{children}</div>
</div>
</>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useState, useCallback } from "react";
export type CollapseState = "full" | "icons" | "hidden";
const STORAGE_KEY = "sidebar-collapse-state";
const CYCLE_ORDER: CollapseState[] = ["full", "icons", "hidden"];
function readState(): CollapseState {
if (typeof window === "undefined") return "full";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "full" || stored === "icons" || stored === "hidden") return stored;
return "full";
}
export function useSidebarState() {
const [collapseState, setCollapseStateRaw] = useState<CollapseState>(readState);
const setCollapseState = useCallback((state: CollapseState) => {
setCollapseStateRaw(state);
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, state);
}
}, []);
const cycleCollapseState = useCallback(() => {
setCollapseState(
CYCLE_ORDER[(CYCLE_ORDER.indexOf(readState()) + 1) % CYCLE_ORDER.length]
);
}, [setCollapseState]);
return { collapseState, setCollapseState, cycleCollapseState };
}
@@ -0,0 +1,264 @@
interface IconProps {
className?: string;
}
// Fallback icon (small circle) when no icon is specified
export function DefaultSectionIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
</svg>
);
}
// --- Generic icons reused across sections ---
export function ShieldCheckIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
);
}
export function UsersIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
);
}
export function CpuChipIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5M4.5 15.75H3m18 0h-1.5M8.25 19.5V21m0-18v1.5m0 15V21m4.5-18v1.5m0 15V21M16.5 3v1.5m0 15V21M6.75 6.75h10.5v10.5H6.75V6.75z" />
</svg>
);
}
export function GearIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
export function MagnifyingGlassIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
);
}
export function BellIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
);
}
export function FolderIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
);
}
export function ChartBarIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
);
}
export function ClockIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
export function DocumentTextIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);
}
export function CodeBracketIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
);
}
export function ArrowDownTrayIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
);
}
export function PlayCircleIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" />
</svg>
);
}
export function InformationCircleIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
);
}
export function BuildingOfficeIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
</svg>
);
}
export function LockClosedIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
);
}
export function WrenchScrewdriverIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17l-5.1 5.1a2.121 2.121 0 01-3-3l5.1-5.1m0 0L3.34 8.09a1.5 1.5 0 010-2.12l.88-.88a1.5 1.5 0 012.12 0l4.08 4.08m0 0l1.415-1.414a2.032 2.032 0 012.871 0l2.872 2.872a2.032 2.032 0 010 2.871l-1.414 1.415m-6.744-6.744L15.17 11.42" />
</svg>
);
}
export function TrashIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
);
}
export function ArrowRightOnRectangleIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
);
}
export function ServerStackIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
);
}
export function ArrowPathIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
</svg>
);
}
export function NoSymbolIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
);
}
export function CloudArrowUpIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z" />
</svg>
);
}
export function RocketLaunchIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
</svg>
);
}
export function BookOpenIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
);
}
export function ComputerDesktopIcon({ className = "w-5 h-5" }: IconProps) {
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>
);
}
export function ListBulletIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
);
}
export function SparklesIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
);
}
export function CircleStackIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
);
}
export function ArrowsPointingOutIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
);
}
export function KeyIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
);
}
File diff suppressed because one or more lines are too long