import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link, Outlet, useNavigate } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import { Button, Disclosure, DisclosurePanel } from "react-aria-components"; import { useAuth } from "@/auth/store"; import InlineError from "@/lib/InlineError"; import { healthQuery, versionQuery } from "@/lib/queries"; import PauseControl from "@/features/pause/PauseControl"; import { diagnosticsBadge } from "./diagnosticsBadge"; import ConfigStatusNotices from "./ConfigStatusNotices"; import SidebarStatus, { ProtectionDot } from "./SidebarStatus"; import AuthorityLine from "@/features/configuration/AuthorityLine"; import { styles as shared } from "@/ui/styles"; import { colors, layers, metrics } from "@/ui/tokens.stylex"; /** The one breakpoint the shell has: below it the sidebar becomes a drawer. */ const WIDE = "@media (min-width: 801px)"; /** * Two labelled groups (ui-visual-redesign.md): MONITORING is what the resolver * is doing, SYSTEM is what it is told to do and how the box is faring. The * three configuration pages sit flat in SYSTEM — "Configuration" is not one * page — with the authority line under them. */ const MONITORING_ITEMS = [ { to: "/overview", label: "Overview" }, { to: "/activity", label: "Activity" }, { to: "/clients", label: "Clients" }, ] as const; const SYSTEM_ITEMS = [ { to: "/configuration/protection", label: "Protection" }, { to: "/configuration/resolution", label: "Resolution" }, { to: "/configuration/system", label: "System" }, { to: "/diagnostics", label: "Diagnostics" }, ] as const; const styles = stylex.create({ navList: { display: "flex", flexDirection: "column", gap: "0.25rem", }, navLink: { display: "flex", alignItems: "center", gap: "0.5rem", minHeight: metrics.hitTarget, borderRadius: metrics.radius, paddingInline: "0.75rem", paddingBlock: "0.375rem", // Carried by every item, active or not: a weight that changes on // navigation would reflow the whole nav list. fontWeight: 500, textDecorationLine: "none", }, navLabel: { flex: 1, }, navGroup: { marginTop: "1.125rem", }, navGroupLabel: { display: "block", paddingInline: "0.75rem", paddingBottom: "0.375rem", fontSize: "0.6875rem", lineHeight: "1rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.09em", color: colors.textMuted, }, /** * Neutral chrome: the mark is the message, and a coloured pill here would be * the page's loudest element on every route. Text and shape carry it. */ badge: { minWidth: "1.25rem", borderRadius: "0.625rem", borderWidth: 1, borderStyle: "solid", borderColor: colors.borderStrong, backgroundColor: colors.surfaceHover, paddingInline: "0.375rem", fontSize: "0.75rem", lineHeight: "1.125rem", fontWeight: 500, textAlign: "center", color: colors.textSecondary, }, /** The control sits with the footer, not in the scrolling nav list above it. */ sidebarFooter: { paddingInline: "1rem", paddingBlock: "0.75rem", }, /** The current page is a soft accent-tinted block, in the accent's own colour. */ navActive: { backgroundColor: colors.primarySurface, color: colors.primaryOnSurface, }, navIdle: { backgroundColor: { default: "transparent", ":hover": colors.surfaceHover }, color: { default: colors.textSecondary, ":hover": colors.text }, transitionProperty: metrics.transitionProperty, transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" }, }, versionFooter: { paddingInline: "1rem", paddingBlock: "0.75rem", fontSize: "0.75rem", lineHeight: "1rem", color: colors.textMuted, }, logoutRow: { display: "flex", alignItems: "center", gap: "0.5rem", }, /** * In a 14rem column the button and its error stack, so the button spans the * sidebar and matches the Pause trigger below it. */ sidebarLogout: { flexDirection: "column", alignItems: "stretch", gap: "0.25rem", paddingInline: "1rem", paddingTop: "0.75rem", }, shell: { minHeight: "100dvh", height: { default: null, [WIDE]: "100dvh" }, overflow: { default: null, [WIDE]: "hidden" }, backgroundColor: colors.surface, color: colors.text, display: { default: "block", [WIDE]: "grid" }, gridTemplateColumns: { default: null, [WIDE]: "14rem 1fr" }, }, sidebar: { display: { default: "none", [WIDE]: "flex" }, flexDirection: { default: null, [WIDE]: "column" }, minHeight: { default: null, [WIDE]: 0 }, overflow: { default: null, [WIDE]: "hidden" }, backgroundColor: colors.surfaceRaised, borderRightWidth: 1, borderRightStyle: "solid", borderRightColor: colors.border, }, brand: { paddingInline: "1rem", paddingBlock: "1.25rem", fontSize: "1rem", lineHeight: "1.5rem", fontWeight: 650, letterSpacing: "-0.015em", }, sidebarNav: { flex: 1, minHeight: { default: null, [WIDE]: 0 }, overflowY: { default: null, [WIDE]: "auto" }, paddingInline: "0.5rem", }, column: { display: "flex", minHeight: { default: "100dvh", [WIDE]: 0 }, minWidth: { default: null, [WIDE]: 0 }, flexDirection: "column", }, /** * Narrow only: on WIDE the sidebar carries everything this row held. Sticky, * so the brand, the protection state and the menu stay in reach down a long * page. */ header: { display: { default: "flex", [WIDE]: "none" }, position: "sticky", top: 0, zIndex: layers.tooltip, alignItems: "center", gap: "0.75rem", backgroundColor: colors.surfaceRaised, borderBottomWidth: 1, borderBottomStyle: "solid", borderBottomColor: colors.border, paddingInline: "1rem", paddingBlock: "0.5rem", }, narrowOnly: { display: { default: null, [WIDE]: "none" }, }, narrowBrand: { fontSize: "1rem", lineHeight: "1.5rem", fontWeight: 650, letterSpacing: "-0.015em", display: { default: null, [WIDE]: "none" }, }, headerRight: { marginLeft: "auto", display: "flex", alignItems: "center", gap: "0.75rem", }, drawer: { borderBottomWidth: 1, borderBottomStyle: "solid", borderBottomColor: colors.border, display: { default: null, [WIDE]: "none" }, }, drawerNav: { paddingInline: "0.5rem", paddingBlock: "0.5rem", }, main: { flex: 1, minHeight: { default: null, [WIDE]: 0 }, minWidth: { default: null, [WIDE]: 0 }, overflowY: { default: null, [WIDE]: "auto" }, padding: "1rem", }, }); function NavItem({ to, label, onNavigate, badge, }: { to: string; label: string; onNavigate?: () => void; badge?: { text: string; label: string } | null; }) { return (
  • {label} {badge !== undefined && badge !== null && ( {badge.text} )}
  • ); } function NavLinks({ onNavigate }: { onNavigate?: () => void }) { const health = useQuery(healthQuery()); const badge = diagnosticsBadge(health.data, health.isError); const monitoringId = useId(); const systemId = useId(); return ( <>
    {/* A span, not a heading: the sidebar label is not a section of the page, and an h2 here lands in the middle of the page's own outline. */} Monitoring
    System
    ); } /** * The sidebar's foot, in both renderings. Pause is a runtime action on the whole * resolver rather than on the page in front of the reader, which is why it sits * with the status block and the version label instead of in the header of every * route. */ function SidebarFooter() { return ( <>
    ); } function VersionFooter() { const { data } = useQuery(versionQuery()); return (
    {data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`}
    ); } function LogoutButton({ style }: { style?: stylex.StyleXStyles }) { const { authRequired, logout } = useAuth(); const navigate = useNavigate(); const [error, setError] = useState(null); if (authRequired !== true) return null; return (
    {error !== null && }
    ); } export default function AppShell() { const [drawerOpen, setDrawerOpen] = useState(false); return (
    {/* The column itself is the disclosure: the trigger sits in the header and the panel opens below the notices, so any wrapper around only the two would have to cut across the column's own flex children. */}
    nxdns
    {/* React Aria only hides a collapsed panel; the shell drops it instead, so a closed drawer keeps no second health poll and no second Pause control alive behind the header. */} {drawerOpen && ( <> )}
    ); }