initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,264 @@
|
||||
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,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Menu,
|
||||
Network,
|
||||
PanelLeftClose,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { logout } from '../../lib/api.ts'
|
||||
import type { Health } from '../../lib/types.ts'
|
||||
import { HealthPill } from './HealthPill.tsx'
|
||||
import { ThemeToggle } from './ThemeToggle.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: '/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()
|
||||
|
||||
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 (
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-exported so pages can label collapsed rail items without importing ui. */
|
||||
export { Tooltip }
|
||||
Reference in new issue
Block a user