/** * Main application layout. * * Provides the persistent sidebar navigation and the main content area * for all authenticated pages. The sidebar collapses from 240 px to * icon-only (48 px) on small screens. */ import { useCallback, useEffect, useState } from "react"; import { Badge, Button, makeStyles, mergeClasses, MessageBar, MessageBarBody, MessageBarTitle, Text, tokens, Tooltip, } from "@fluentui/react-components"; import { GridRegular, MapRegular, ShieldRegular, SettingsRegular, HistoryRegular, ListRegular, SignOutRegular, NavigationRegular, } from "@fluentui/react-icons"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { useAuth } from "../providers/AuthProvider"; import { useServerStatus } from "../hooks/useServerStatus"; import { useBlocklistStatus } from "../hooks/useBlocklist"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const SIDEBAR_FULL = "240px"; const SIDEBAR_COLLAPSED = "48px"; const useStyles = makeStyles({ root: { display: "flex", height: "100vh", overflow: "hidden", backgroundColor: tokens.colorNeutralBackground3, }, // Sidebar sidebar: { display: "flex", flexDirection: "column", width: SIDEBAR_FULL, minWidth: SIDEBAR_COLLAPSED, backgroundColor: tokens.colorNeutralBackground1, borderRightWidth: "1px", borderRightStyle: "solid", borderRightColor: tokens.colorNeutralStroke2, transition: "width 200ms ease", overflow: "hidden", flexShrink: 0, // Honour the OS/browser reduced-motion preference. "@media (prefers-reduced-motion: reduce)": { transition: "none", }, }, sidebarCollapsed: { width: SIDEBAR_COLLAPSED, }, sidebarHeader: { display: "flex", alignItems: "center", height: "52px", paddingLeft: tokens.spacingHorizontalM, paddingRight: tokens.spacingHorizontalS, gap: tokens.spacingHorizontalS, borderBottomWidth: "1px", borderBottomStyle: "solid", borderBottomColor: tokens.colorNeutralStroke2, flexShrink: 0, }, logo: { fontWeight: 600, fontSize: "16px", whiteSpace: "nowrap", overflow: "hidden", color: tokens.colorBrandForeground1, flexGrow: 1, }, // Nav items list navList: { display: "flex", flexDirection: "column", gap: "2px", padding: tokens.spacingVerticalS, overflowY: "auto", flexGrow: 1, }, navLinkContent: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS, flexGrow: 1, }, navLink: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS, padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`, borderRadius: tokens.borderRadiusMedium, textDecoration: "none", color: tokens.colorNeutralForeground2, whiteSpace: "nowrap", overflow: "hidden", ":hover": { backgroundColor: tokens.colorNeutralBackground1Hover, color: tokens.colorNeutralForeground1, }, }, navLinkActive: { backgroundColor: tokens.colorNeutralBackground1Selected, color: tokens.colorBrandForeground1, ":hover": { backgroundColor: tokens.colorNeutralBackground1Selected, }, }, navLabel: { fontSize: "14px", lineHeight: "20px", overflow: "hidden", textOverflow: "ellipsis", }, // Sidebar footer (logout) sidebarFooter: { borderTopWidth: "1px", borderTopStyle: "solid", borderTopColor: tokens.colorNeutralStroke2, padding: tokens.spacingVerticalS, flexShrink: 0, }, versionText: { display: "block", color: tokens.colorNeutralForeground4, fontSize: "11px", paddingLeft: tokens.spacingHorizontalS, paddingRight: tokens.spacingHorizontalS, paddingBottom: tokens.spacingVerticalXS, whiteSpace: "nowrap", overflow: "hidden", }, // Main content main: { display: "flex", flexDirection: "column", flexGrow: 1, overflow: "auto", }, warningBar: { flexShrink: 0, paddingLeft: tokens.spacingHorizontalM, paddingRight: tokens.spacingHorizontalM, paddingTop: tokens.spacingVerticalXS, paddingBottom: tokens.spacingVerticalXS, }, content: { flexGrow: 1, maxWidth: "1440px", width: "100%", margin: "0 auto", padding: tokens.spacingVerticalL, }, }); // --------------------------------------------------------------------------- // Nav item data // --------------------------------------------------------------------------- interface NavItem { label: string; to: string; icon: React.ReactElement; end?: boolean; } const NAV_ITEMS: NavItem[] = [ { label: "Dashboard", to: "/", icon: , end: true }, { label: "World Map", to: "/map", icon: }, { label: "Jails", to: "/jails", icon: }, { label: "History", to: "/history", icon: }, { label: "Blocklists", to: "/blocklists", icon: }, { label: "Configuration", to: "/config", icon: }, ]; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * Main application shell with sidebar navigation and content area. * * Renders child routes via ``. Use inside React Router * as a layout route wrapping all authenticated pages. */ export function MainLayout(): React.JSX.Element { const styles = useStyles(); const { logout } = useAuth(); const navigate = useNavigate(); // Initialise collapsed based on screen width so narrow viewports start // with the icon-only sidebar rather than the full-width one. const [collapsed, setCollapsed] = useState(() => window.innerWidth < 640); const { status } = useServerStatus(); const { hasErrors: blocklistHasErrors } = useBlocklistStatus(); /** True only after the first successful poll and fail2ban is unreachable. */ const serverOffline = status !== null && !status.online; // Auto-collapse / auto-expand when the viewport crosses the 640 px breakpoint. useEffect(() => { const mq = window.matchMedia("(max-width: 639px)"); const handler = (e: MediaQueryListEvent): void => { setCollapsed(e.matches); }; mq.addEventListener("change", handler); return (): void => { mq.removeEventListener("change", handler); }; }, []); const toggleCollapse = useCallback(() => { setCollapsed((prev) => !prev); }, []); const handleLogout = useCallback(async () => { await logout(); navigate("/login", { replace: true }); }, [logout, navigate]); return (
{/* ---------------------------------------------------------------- */} {/* Sidebar */} {/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */} {/* Main content */} {/* ---------------------------------------------------------------- */}
{/* Connection health warning — shown when fail2ban is unreachable */} {serverOffline && (
fail2ban Unreachable The connection to the fail2ban server has been lost. Some features may be temporarily unavailable.
)} {/* Blocklist import error warning — shown when the last scheduled import had errors */} {blocklistHasErrors && (
Blocklist Import Errors The most recent blocklist import encountered errors. Check the Blocklists page for details.
)}
); }