Initial commit baby!

This commit is contained in:
Rich Teslow
2026-04-13 18:15:41 -07:00
parent 10e6227931
commit 6b0fc9fdf1
99 changed files with 34064 additions and 3 deletions
@@ -0,0 +1,29 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import TopBar from './TopBar';
export default function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
{/* Mobile backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar mobileOpen={sidebarOpen} onMobileClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<TopBar onMenuClick={() => setSidebarOpen((o) => !o)} />
<main className="flex-1 overflow-auto p-3 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,308 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Router, Wifi, AlertTriangle, X } from 'lucide-react';
import { searchApi } from '../../services/api';
import { format } from 'date-fns';
import clsx from 'clsx';
type SearchDevice = {
id: number; name: string; ip_address: string;
model?: string; device_type: string; status: string;
};
type SearchClient = {
mac_address: string; hostname?: string; ip_address?: string;
device_id: number; device_name?: string; active: boolean;
};
type SearchEvent = {
id: number; message: string; severity: string;
event_time: string; topic?: string; device_name?: string;
};
type SearchResults = { devices: SearchDevice[]; clients: SearchClient[]; events: SearchEvent[] };
const SEVERITY_ICON_CLS: Record<string, string> = {
error: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
critical: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
};
export default function GlobalSearch() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Debounce the query input
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 280);
return () => clearTimeout(t);
}, [query]);
// Fetch when debounced query is long enough
useEffect(() => {
if (debouncedQuery.length < 2) { setResults(null); return; }
setLoading(true);
searchApi.search(debouncedQuery)
.then((r) => { setResults(r.data); setHighlighted(0); })
.catch(() => setResults(null))
.finally(() => setLoading(false));
}, [debouncedQuery]);
// Ctrl+K / Cmd+K global shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Flat list for keyboard navigation index tracking
const deviceCount = results?.devices.length ?? 0;
const clientCount = results?.clients.length ?? 0;
const eventCount = results?.events.length ?? 0;
const totalCount = deviceCount + clientCount + eventCount;
const goToResult = useCallback((kind: 'device' | 'client' | 'event', id: string | number) => {
setOpen(false);
setQuery('');
setResults(null);
if (kind === 'device') navigate(`/devices/${id}`);
else if (kind === 'client') navigate('/clients');
else navigate('/events');
}, [navigate]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open || totalCount === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => (h + 1) % totalCount);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlighted((h) => (h - 1 + totalCount) % totalCount);
} else if (e.key === 'Enter' && results) {
e.preventDefault();
if (highlighted < deviceCount) {
goToResult('device', results.devices[highlighted].id);
} else if (highlighted < deviceCount + clientCount) {
goToResult('client', results.clients[highlighted - deviceCount].mac_address);
} else {
goToResult('event', results.events[highlighted - deviceCount - clientCount].id);
}
} else if (e.key === 'Escape') {
setOpen(false);
setQuery('');
}
};
const hasResults = totalCount > 0;
const showDropdown = open && query.length >= 2;
return (
<div ref={containerRef} className="relative flex-1 max-w-lg">
{/* Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-slate-500 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Search devices, clients, events… (Ctrl+K)"
className="w-full pl-9 pr-8 py-1.5 text-sm bg-gray-100 dark:bg-slate-700/80 border border-transparent focus:border-blue-500 focus:bg-white dark:focus:bg-slate-800 rounded-lg outline-none transition-colors text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500"
/>
{query && (
<button
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Dropdown */}
{showDropdown && (
<div className="absolute top-full left-0 right-0 mt-1.5 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[500px] flex flex-col">
<div className="overflow-y-auto flex-1">
{loading && (
<div className="px-4 py-6 text-sm text-center text-gray-400 dark:text-slate-500">
Searching…
</div>
)}
{!loading && !hasResults && (
<div className="px-4 py-8 text-sm text-center text-gray-400 dark:text-slate-500">
No results for <span className="font-medium text-gray-600 dark:text-slate-300">"{query}"</span>
</div>
)}
{!loading && hasResults && results && (
<>
{/* ── Devices ── */}
{results.devices.length > 0 && (
<div>
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
Devices
</div>
{results.devices.map((d, i) => (
<button
key={d.id}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
highlighted === i
? 'bg-blue-50 dark:bg-blue-900/20'
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
)}
onMouseEnter={() => setHighlighted(i)}
onClick={() => goToResult('device', d.id)}
>
<div className={clsx(
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
d.status === 'online'
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-gray-100 dark:bg-slate-700'
)}>
<Router className={clsx(
'w-3.5 h-3.5',
d.status === 'online' ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-slate-500'
)} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{d.name}</div>
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
{d.ip_address}{d.model ? ` · ${d.model}` : ''}
</div>
</div>
<span className={clsx(
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
d.status === 'online'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-400'
)}>
{d.status}
</span>
</button>
))}
</div>
)}
{/* ── Clients ── */}
{results.clients.length > 0 && (
<div>
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
Clients
</div>
{results.clients.map((c, i) => (
<button
key={c.mac_address}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
highlighted === deviceCount + i
? 'bg-blue-50 dark:bg-blue-900/20'
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
)}
onMouseEnter={() => setHighlighted(deviceCount + i)}
onClick={() => goToResult('client', c.mac_address)}
>
<div className="w-7 h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
<Wifi className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{c.hostname || c.mac_address}
</div>
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
{c.hostname ? `${c.mac_address} · ` : ''}
{c.ip_address ? `${c.ip_address} · ` : ''}
{c.device_name || ''}
</div>
</div>
{!c.active && (
<span className="text-xs text-gray-400 dark:text-slate-500 flex-shrink-0">inactive</span>
)}
</button>
))}
</div>
)}
{/* ── Events ── */}
{results.events.length > 0 && (
<div>
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
Events
</div>
{results.events.map((ev, i) => (
<button
key={ev.id}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
highlighted === deviceCount + clientCount + i
? 'bg-blue-50 dark:bg-blue-900/20'
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
)}
onMouseEnter={() => setHighlighted(deviceCount + clientCount + i)}
onClick={() => goToResult('event', ev.id)}
>
<div className={clsx(
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
)}>
<AlertTriangle className="w-3.5 h-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{ev.message}</div>
<div className="text-xs text-gray-400 dark:text-slate-500">
{ev.device_name ? `${ev.device_name} · ` : ''}
{format(new Date(ev.event_time), 'MMM d, HH:mm')}
</div>
</div>
<span className={clsx(
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
)}>
{ev.severity}
</span>
</button>
))}
</div>
)}
</>
)}
</div>
{/* Footer keyboard hints */}
{hasResults && (
<div className="flex-shrink-0 px-3 py-2 border-t border-gray-100 dark:border-slate-700 flex items-center gap-4 text-xs text-gray-400 dark:text-slate-500">
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↑↓</kbd> navigate</span>
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↵</kbd> open</span>
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">Esc</kbd> close</span>
</div>
)}
</div>
)}
</div>
);
}
+428
View File
@@ -0,0 +1,428 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
Server, Globe, Clock, Shield,
} from 'lucide-react';
import clsx from 'clsx';
import { APP_VERSION } from '../../version';
import { devicesApi } from '../../services/api';
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/devices', icon: Router, label: 'Devices' },
{ to: '/clients', icon: Users, label: 'Clients' },
{ to: '/events', icon: Bell, label: 'Events' },
{ to: '/topology', icon: GitBranch, label: 'Topology' },
{ to: '/backups', icon: HardDrive, label: 'Backups' },
];
const switchSubItems = [
{ to: '/switches', icon: Layers, label: 'Overview' },
{ to: '/switches/settings', icon: SlidersHorizontal, label: 'Settings' },
];
const routerSubItems = [
{ to: '/routers', icon: Router, label: 'Overview' },
{ to: '/routers/settings', icon: SlidersHorizontal, label: 'Settings' },
];
const wirelessSubItems = [
{ to: '/wireless', icon: Wifi, label: 'Overview' },
{ to: '/wireless/clients', icon: Users, label: 'Clients' },
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
];
const networkServicesSubItems = [
{ to: '/network-services', icon: Network, label: 'Overview' },
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
];
interface SidebarProps {
mobileOpen: boolean;
onMobileClose: () => void;
}
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const location = useLocation();
const { data: devices = [] } = useQuery({
queryKey: ['devices'],
queryFn: () => devicesApi.list().then(r => r.data),
staleTime: 60_000,
});
const hasSwitches = devices.some(d => d.device_type === 'switch');
const hasRouters = devices.some(d => d.device_type === 'router');
const hasWireless = devices.some(d => d.device_type === 'wireless_ap');
const switchesActive = location.pathname.startsWith('/switches');
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
const effectiveSwitchesOpen = switchesOpen || switchesActive;
const routersActive = location.pathname.startsWith('/routers');
const [routersOpen, setRoutersOpen] = useState(routersActive);
const effectiveRoutersOpen = routersOpen || routersActive;
const wirelessActive = location.pathname.startsWith('/wireless');
const [wirelessOpen, setWirelessOpen] = useState(wirelessActive);
const effectiveWirelessOpen = wirelessOpen || wirelessActive;
const networkServicesActive = location.pathname.startsWith('/network-services');
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
// On mobile the sidebar is always expanded (never icon-only)
const isCollapsed = collapsed;
const handleNavClick = () => {
// Close mobile drawer when navigating
onMobileClose();
};
return (
<aside
className={clsx(
// Base styles
'flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col transition-all duration-200',
// Mobile: fixed drawer that slides in/out
'fixed inset-y-0 left-0 z-50',
mobileOpen ? 'translate-x-0' : '-translate-x-full',
// Desktop: static in flex layout, normal collapse behaviour
'md:relative md:translate-x-0',
isCollapsed ? 'md:w-14' : 'md:w-56',
// Mobile always full width
'w-64',
)}
>
{/* Logo */}
<div
className={clsx(
'flex items-center border-b border-gray-200 dark:border-slate-700 py-5',
isCollapsed ? 'md:justify-center md:px-2 px-4 gap-3' : 'gap-3 px-4'
)}
>
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
<Network className="w-4 h-4 text-white" />
</div>
{/* Always show text on mobile; respect collapsed on desktop */}
<div className={clsx(isCollapsed && 'md:hidden')}>
<div className="text-sm font-bold text-gray-900 dark:text-white leading-none">Mikrotik</div>
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 leading-none">Manager</div>
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1 leading-none">{APP_VERSION}</div>
</div>
{/* Mobile close button */}
<button
onClick={onMobileClose}
className="ml-auto p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 md:hidden"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
title={isCollapsed ? label : undefined}
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
</NavLink>
))}
{/* Switches group */}
{hasSwitches && (
isCollapsed ? (
<NavLink
to="/switches"
title="Switches"
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
isActive || switchesActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Layers className="w-4 h-4 flex-shrink-0" />
</NavLink>
) : (
<>
<button
onClick={() => setSwitchesOpen(o => !o)}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
switchesActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)}
>
<Layers className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">Switches</span>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveSwitchesOpen ? 'rotate-0' : '-rotate-90')} />
</button>
{effectiveSwitchesOpen && (
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
{switchSubItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{label}
</NavLink>
))}
</div>
)}
</>
)
)}
{/* Routers group */}
{hasRouters && (
isCollapsed ? (
<NavLink
to="/routers"
title="Routers"
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
isActive || routersActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Router className="w-4 h-4 flex-shrink-0" />
</NavLink>
) : (
<>
<button
onClick={() => setRoutersOpen(o => !o)}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
routersActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)}
>
<Router className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">Routers</span>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveRoutersOpen ? 'rotate-0' : '-rotate-90')} />
</button>
{effectiveRoutersOpen && (
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
{routerSubItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{label}
</NavLink>
))}
</div>
)}
</>
)
)}
{/* Wireless group */}
{hasWireless && (
isCollapsed ? (
<NavLink
to="/wireless"
title="Wireless"
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
isActive || wirelessActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Wifi className="w-4 h-4 flex-shrink-0" />
</NavLink>
) : (
<>
<button
onClick={() => setWirelessOpen(o => !o)}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
wirelessActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)}
>
<Wifi className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">Wireless</span>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveWirelessOpen ? 'rotate-0' : '-rotate-90')} />
</button>
{effectiveWirelessOpen && (
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
{wirelessSubItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{label}
</NavLink>
))}
</div>
)}
</>
)
)}
{/* Network Services group — always shown */}
{isCollapsed ? (
<NavLink
to="/network-services"
title="Network Services"
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
isActive || networkServicesActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Network className="w-4 h-4 flex-shrink-0" />
</NavLink>
) : (
<>
<button
onClick={() => setNetworkServicesOpen(o => !o)}
className={clsx(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
networkServicesActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)}
>
<Network className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">Network Services</span>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveNetworkServicesOpen ? 'rotate-0' : '-rotate-90')} />
</button>
{effectiveNetworkServicesOpen && (
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
{networkServicesSubItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{label}
</NavLink>
))}
</div>
)}
</>
)}
</nav>
{/* Settings + collapse toggle */}
<div className="px-2 py-3 border-t border-gray-200 dark:border-slate-700 space-y-0.5">
<NavLink
to="/settings"
title={isCollapsed ? 'Settings' : undefined}
onClick={handleNavClick}
className={({ isActive }) =>
clsx(
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
)
}
>
<Settings className="w-4 h-4 flex-shrink-0" />
<span className={clsx(isCollapsed && 'md:hidden')}>Settings</span>
</NavLink>
{/* Collapse toggle — desktop only */}
<button
onClick={() => setCollapsed(c => !c)}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={clsx(
'hidden md:flex w-full items-center rounded-lg text-sm font-medium transition-colors duration-150 text-gray-500 dark:text-slate-500 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300',
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
)}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4 flex-shrink-0" />
) : (
<><ChevronLeft className="w-4 h-4 flex-shrink-0" />Collapse</>
)}
</button>
</div>
</aside>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useNavigate } from 'react-router-dom';
import { Sun, Moon, LogOut, User, Menu } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useThemeStore } from '../../store/themeStore';
import GlobalSearch from './GlobalSearch';
interface TopBarProps {
onMenuClick: () => void;
}
export default function TopBar({ onMenuClick }: TopBarProps) {
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useThemeStore();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<header className="h-14 flex-shrink-0 flex items-center gap-2 sm:gap-4 px-3 sm:px-6 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
{/* Hamburger — mobile only */}
<button
onClick={onMenuClick}
className="md:hidden p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
aria-label="Open menu"
>
<Menu className="w-5 h-5" />
</button>
<GlobalSearch />
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0 ml-auto">
{/* Theme toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="w-4 h-4" />
) : (
<Sun className="w-4 h-4" />
)}
</button>
{/* User info — hide label on very small screens */}
<div className="flex items-center gap-2 px-2 sm:px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-slate-700">
<User className="w-3.5 h-3.5 text-gray-500 dark:text-slate-400 flex-shrink-0" />
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-slate-200">
{user?.username}
</span>
</div>
{/* Logout */}
<button
onClick={handleLogout}
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Logout"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</header>
);
}