import { useCallback, useEffect, useState } from 'react' import type { ReactNode } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' import type { LucideIcon } from 'lucide-react' import { Database, FileCode2, Globe, LayoutDashboard, ListChecks, Lock, LogOut, Menu, Network, PanelLeftClose, ScrollText, Settings, Users, X, } from 'lucide-react' import { cn } from '../../lib/cn.ts' import { logout } from '../../lib/api.ts' import { useWhoami } from '../../lib/session.ts' import type { Health } from '../../lib/types.ts' import { HealthPill } from './HealthPill.tsx' import { ThemeToggle } from './ThemeToggle.tsx' import { WhoamiProvider } from './WhoamiProvider.tsx' import { Tooltip } from '../ui/Tooltip.tsx' interface NavItem { to: string label: string icon: LucideIcon end?: boolean } const NAV: NavItem[] = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true }, { to: '/blocklists', label: 'Blocklists', icon: ListChecks }, { to: '/domains', label: 'Domains', icon: Globe }, { to: '/local-zones', label: 'Local zones', icon: FileCode2 }, { to: '/clients', label: 'Clients', icon: Users }, { to: '/upstreams', label: 'Upstreams', icon: Network }, { to: '/logs', label: 'Logs', icon: ScrollText }, { to: '/cache', label: 'Cache', icon: Database }, { to: '/settings', label: 'Settings', icon: Settings }, ] const MOBILE_QUERY = '(max-width: 1023px)' export function AppShell({ children, health, nodeId, }: { children: ReactNode health?: Health nodeId?: string }) { const [collapsed, setCollapsed] = useState(false) const [sheetOpen, setSheetOpen] = useState(false) const [isMobile, setIsMobile] = useState(false) const [signingOut, setSigningOut] = useState(false) const location = useLocation() const navigate = useNavigate() // The provider is the app's own, so the shell's header can read it too. const { readOnly, identity } = useWhoami() useEffect(() => { const query = window.matchMedia(MOBILE_QUERY) const sync = (): void => { setIsMobile(query.matches) if (!query.matches) setSheetOpen(false) } sync() query.addEventListener('change', sync) return () => query.removeEventListener('change', sync) }, []) // Any navigation closes the mobile sheet. useEffect(() => { setSheetOpen(false) }, [location.pathname]) useEffect(() => { if (!sheetOpen) return const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setSheetOpen(false) } document.addEventListener('keydown', onKey) const previous = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', onKey) document.body.style.overflow = previous } }, [sheetOpen]) const onSignOut = useCallback(() => { setSigningOut(true) logout() .catch(() => { /* the token is cleared either way */ }) .finally(() => { setSigningOut(false) navigate('/auth/login', { replace: true }) }) }, [navigate]) const rail = !isMobile && collapsed return (
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries the hamburger that opens the sheet instead. */}
{isMobile ? ( ) : ( )}
fancy-dns
{nodeId !== undefined && nodeId !== '' && ( {nodeId} )} {health !== undefined && }
{/* Desktop sidebar. */} {/* Mobile sheet. */} {sheetOpen && (
)}
{readOnly && (

)} {children}
) } /** Re-exported so pages can label collapsed rail items without importing ui. */ export { Tooltip }