Initial commit baby!
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user