feat: redesign sidebar with icons, collapse toggle, and improved theme
- Add lucide icons to all navigation items - Collapsible sidebar with icon-only mode and tooltips - Narrower sidebar (w-60 vs w-64), compact top bar (h-12 vs h-14) - Better search bar UX in top bar with keyboard shortcut hint - Refined dark theme with better contrast and separation - Custom thin scrollbar styling - Backdrop blur for sidebar and top bar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7dbd2c1414
commit
9a33cef951
|
|
@ -21,11 +21,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin p-5">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, createContext, useContext } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Bot,
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
Server,
|
||||
Activity,
|
||||
Terminal,
|
||||
Shield,
|
||||
FileSearch,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ---------- Sidebar context for collapse state ---------- */
|
||||
|
||||
interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
setCollapsed: () => {},
|
||||
});
|
||||
|
||||
export const useSidebar = () => useContext(SidebarContext);
|
||||
|
||||
/* ---------- Nav config ---------- */
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
children?: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
const iconClass = 'h-4 w-4 shrink-0';
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> },
|
||||
{
|
||||
label: 'Agent Config',
|
||||
href: '/agent-config',
|
||||
icon: <Bot className={iconClass} />,
|
||||
children: [
|
||||
{ label: 'Engine & Prompt', href: '/agent-config' },
|
||||
{ label: 'SDK Config', href: '/agent-config/sdk' },
|
||||
|
|
@ -23,11 +60,12 @@ const navItems: NavItem[] = [
|
|||
{ label: 'Hooks', href: '/agent-config/hooks' },
|
||||
],
|
||||
},
|
||||
{ label: 'Runbooks', href: '/runbooks' },
|
||||
{ label: 'Standing Orders', href: '/standing-orders' },
|
||||
{ label: 'Runbooks', href: '/runbooks', icon: <BookOpen className={iconClass} /> },
|
||||
{ label: 'Standing Orders', href: '/standing-orders', icon: <ClipboardList className={iconClass} /> },
|
||||
{
|
||||
label: 'Servers',
|
||||
href: '/servers',
|
||||
icon: <Server className={iconClass} />,
|
||||
children: [
|
||||
{ label: 'All Servers', href: '/servers' },
|
||||
{ label: 'Clusters', href: '/servers/clusters' },
|
||||
|
|
@ -36,15 +74,17 @@ const navItems: NavItem[] = [
|
|||
{
|
||||
label: 'Monitoring',
|
||||
href: '/monitoring/alert-rules',
|
||||
icon: <Activity className={iconClass} />,
|
||||
children: [
|
||||
{ label: 'Alert Rules', href: '/monitoring/alert-rules' },
|
||||
{ label: 'Health Checks', href: '/monitoring/health-checks' },
|
||||
],
|
||||
},
|
||||
{ label: 'Terminal', href: '/terminal' },
|
||||
{ label: 'Terminal', href: '/terminal', icon: <Terminal className={iconClass} /> },
|
||||
{
|
||||
label: 'Security',
|
||||
href: '/security/risk-rules',
|
||||
icon: <Shield className={iconClass} />,
|
||||
children: [
|
||||
{ label: 'Risk Rules', href: '/security/risk-rules' },
|
||||
{ label: 'Credentials', href: '/security/credentials' },
|
||||
|
|
@ -53,16 +93,19 @@ const navItems: NavItem[] = [
|
|||
{
|
||||
label: 'Audit',
|
||||
href: '/audit/logs',
|
||||
icon: <FileSearch className={iconClass} />,
|
||||
children: [
|
||||
{ label: 'Logs', href: '/audit/logs' },
|
||||
{ label: 'Session Replay', href: '/audit/replay' },
|
||||
],
|
||||
},
|
||||
{ label: 'Communication', href: '/communication' },
|
||||
{ label: 'Tenants', href: '/tenants' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
{ label: 'Communication', href: '/communication', icon: <MessageSquare className={iconClass} /> },
|
||||
{ label: 'Tenants', href: '/tenants', icon: <Building2 className={iconClass} /> },
|
||||
{ label: 'Settings', href: '/settings', icon: <Settings className={iconClass} /> },
|
||||
];
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
function isActive(pathname: string, href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
}
|
||||
|
|
@ -72,81 +115,140 @@ function isGroupActive(pathname: string, item: NavItem) {
|
|||
return item.children?.some((c) => isActive(pathname, c.href)) ?? false;
|
||||
}
|
||||
|
||||
/* ---------- Tooltip for collapsed mode ---------- */
|
||||
|
||||
function Tooltip({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="relative group/tip">
|
||||
{children}
|
||||
<div
|
||||
className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2.5 py-1 rounded-md
|
||||
bg-popover text-popover-foreground text-xs font-medium whitespace-nowrap
|
||||
shadow-md border opacity-0 pointer-events-none
|
||||
group-hover/tip:opacity-100 transition-opacity duration-150 z-50"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main component ---------- */
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const toggle = (label: string) =>
|
||||
setExpanded((prev) => ({ ...prev, [label]: !prev[label] }));
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-card border-r flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-xl font-bold">IT0 Admin</h1>
|
||||
<p className="text-xs text-muted-foreground">Operations Console</p>
|
||||
<SidebarContext.Provider value={{ collapsed, setCollapsed }}>
|
||||
<aside
|
||||
className={cn(
|
||||
'bg-card/50 backdrop-blur-sm border-r flex flex-col transition-all duration-200 ease-in-out',
|
||||
collapsed ? 'w-16' : 'w-60',
|
||||
)}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<div className={cn('border-b flex items-center', collapsed ? 'px-3 py-4 justify-center' : 'px-4 py-4')}>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-primary font-bold text-sm">IT</span>
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold tracking-tight truncate">IT0 Admin</h1>
|
||||
<p className="text-[10px] text-muted-foreground leading-none mt-0.5">Operations Console</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-2 px-2 space-y-0.5 overflow-y-auto overflow-x-hidden">
|
||||
{navItems.map((item) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const groupActive = isGroupActive(pathname, item);
|
||||
const isOpen = expanded[item.label] ?? groupActive;
|
||||
|
||||
/* ---- Collapsed: icon-only with tooltip ---- */
|
||||
if (collapsed) {
|
||||
const linkTarget = hasChildren
|
||||
? item.children![0].href
|
||||
: item.href;
|
||||
return (
|
||||
<Tooltip key={item.label} label={item.label}>
|
||||
<Link
|
||||
href={linkTarget}
|
||||
className={cn(
|
||||
'flex items-center justify-center h-9 w-full rounded-md transition-colors',
|
||||
groupActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Expanded: no children ---- */
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-md text-[13px] transition-colors',
|
||||
isActive(pathname, item.href)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.icon}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Expanded: with children ---- */
|
||||
return (
|
||||
<div key={item.label}>
|
||||
<button
|
||||
onClick={() => toggle(item.label)}
|
||||
className={cn(
|
||||
'flex items-center justify-between w-full px-3 py-2 rounded-md text-sm transition-colors',
|
||||
'flex items-center gap-2.5 w-full px-2.5 py-2 rounded-md text-[13px] transition-colors',
|
||||
groupActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<svg
|
||||
{item.icon}
|
||||
<span className="flex-1 text-left truncate">{item.label}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 transition-transform',
|
||||
'h-3.5 w-3.5 shrink-0 transition-transform duration-200',
|
||||
isOpen && 'rotate-90',
|
||||
)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
<div className="ml-[18px] mt-0.5 space-y-0.5 border-l border-border/50 pl-2.5">
|
||||
{item.children!.map((child) => (
|
||||
<Link
|
||||
key={child.href}
|
||||
href={child.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-colors',
|
||||
'flex items-center px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
isActive(pathname, child.href)
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span>{child.label}</span>
|
||||
<span className="truncate">{child.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -155,6 +257,28 @@ export function Sidebar() {
|
|||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle at bottom */}
|
||||
<div className="border-t p-2">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 w-full rounded-md text-xs text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground transition-colors',
|
||||
collapsed ? 'justify-center h-9' : 'px-2.5 py-2',
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeft className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<PanelLeftClose className="h-4 w-4 shrink-0" />
|
||||
<span>Collapse</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import { useTenantStore } from '@/stores/zustand/tenant-store';
|
||||
import { Search, Bell } from 'lucide-react';
|
||||
|
||||
export function TopBar() {
|
||||
const { currentTenant } = useTenantStore();
|
||||
|
||||
return (
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<kbd className="px-2 py-1 text-xs bg-muted rounded border text-muted-foreground">
|
||||
Cmd+K
|
||||
<header className="h-12 border-b bg-card/50 backdrop-blur-sm flex items-center justify-between px-4 shrink-0">
|
||||
{/* Left: search / command palette */}
|
||||
<button
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border/60
|
||||
text-muted-foreground hover:text-foreground hover:border-border
|
||||
transition-colors text-xs w-56"
|
||||
onClick={() => {
|
||||
/* TODO: open command palette */
|
||||
}}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left">Search...</span>
|
||||
<kbd className="text-[10px] bg-muted/60 px-1.5 py-0.5 rounded border border-border/40 font-mono">
|
||||
⌘K
|
||||
</kbd>
|
||||
<span className="text-sm text-muted-foreground">Command Palette</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
</button>
|
||||
|
||||
{/* Right: tenant + user */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Notification bell */}
|
||||
<button className="relative p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors">
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
{/* Tenant indicator */}
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Tenant: </span>
|
||||
<span className="font-medium">{currentTenant?.name || 'Not selected'}</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm font-medium">
|
||||
|
||||
{/* User avatar */}
|
||||
<button className="w-7 h-7 rounded-full bg-primary/15 border border-primary/20 flex items-center justify-center text-xs font-semibold text-primary hover:bg-primary/25 transition-colors">
|
||||
A
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 213 31% 91%;
|
||||
--primary: 210 100% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 222 47% 11%;
|
||||
--secondary-foreground: 213 31% 91%;
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
--ring: 210 100% 52%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +32,27 @@
|
|||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ const config: Config = {
|
|||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
|
|
|
|||
Loading…
Reference in New Issue