Files
fancy-dns/web/src/components/layout/AppShell.tsx
T
mats 32f940b763
build / build-push (push) Successful in 29s
Add UI editing for local DNS zones
Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
2026-09-28 08:50:50 +02:00

293 lines
10 KiB
TypeScript

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 (
<WhoamiProvider>
<div className="min-h-svh bg-canvas">
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
the hamburger that opens the sheet instead. */}
<header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface-2 px-3">
{isMobile ? (
<button
type="button"
aria-label="Open navigation"
onClick={() => setSheetOpen(true)}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<Menu size={18} strokeWidth={2} aria-hidden="true" />
</button>
) : (
<button
type="button"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => setCollapsed((value) => !value)}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<PanelLeftClose
size={18}
strokeWidth={2}
aria-hidden="true"
className={cn('transition-transform', collapsed && 'rotate-180')}
/>
</button>
)}
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent-soft text-accent">
<Globe size={15} strokeWidth={2.25} aria-hidden="true" />
</span>
<span className="truncate text-sm font-semibold tracking-tight text-fg">fancy-dns</span>
</div>
<div className="ml-auto flex items-center gap-2">
{nodeId !== undefined && nodeId !== '' && (
<span
title={`Node ${nodeId}`}
className="tabular hidden max-w-40 truncate rounded-md border border-line bg-surface px-2 py-1 font-mono text-xs text-fg-muted sm:inline-block"
>
{nodeId}
</span>
)}
{health !== undefined && <HealthPill health={health} />}
<ThemeToggle />
<button
type="button"
aria-label="Sign out"
title="Sign out"
disabled={signingOut}
onClick={onSignOut}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg disabled:opacity-50"
>
<LogOut size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</header>
{/* Desktop sidebar. */}
<aside
className={cn(
'fixed top-14 bottom-0 left-0 z-30 hidden shrink-0 border-r border-line bg-surface transition-[width] duration-200 lg:flex lg:flex-col',
rail ? 'w-16' : 'w-56',
)}
>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent-soft text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
rail && 'justify-center px-0',
)
}
title={rail ? item.label : undefined}
>
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
{!rail && <span className="truncate">{item.label}</span>}
</NavLink>
))}
</nav>
</aside>
{/* Mobile sheet. */}
{sheetOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<button
type="button"
aria-label="Close navigation"
onClick={() => setSheetOpen(false)}
className="absolute inset-0 bg-black/60"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Navigation"
className="absolute inset-y-0 left-0 flex w-64 flex-col border-r border-line bg-surface shadow-panel"
>
<div className="flex h-14 items-center justify-between border-b border-line px-3">
<span className="text-sm font-semibold text-fg">fancy-dns</span>
<button
type="button"
aria-label="Close navigation"
onClick={() => setSheetOpen(false)}
className="inline-flex size-8 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={() => setSheetOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent-soft text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
)
}
>
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
<span className="truncate">{item.label}</span>
</NavLink>
))}
</nav>
</aside>
</div>
)}
<main
className={cn(
'pt-14 transition-[padding] duration-200',
isMobile ? 'px-3 py-4' : rail ? 'pl-16' : 'pl-56',
)}
>
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4 p-3 sm:p-4 lg:p-6">
{readOnly && (
<p
role="status"
className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-fg"
>
<Lock size={14} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
<span>
Read-only session
{identity !== '' && (
<>
{' '}
as <span className="font-mono">{identity}</span>
</>
)}
: this credential may read the node but not change it, so every write control
below is disabled. The daemon answers 403 on the write routes for this key.
</span>
</p>
)}
{children}
</div>
</main>
</div>
</WhoamiProvider>
)
}
/** Re-exported so pages can label collapsed rail items without importing ui. */
export { Tooltip }