Files
BanGUI/frontend/src/layouts/MainLayout.tsx
Lukas 252204ed97 Consolidate frontend storage keys into constants module
- Move magic strings from AuthProvider, MainLayout, and ThemeProvider to
  frontend/src/utils/constants.ts
- Add STORAGE_KEY_AUTHENTICATED, STORAGE_KEY_SIDEBAR_COLLAPSED, and
  STORAGE_KEY_THEME constants with JSDoc descriptions
- Update all three files to import and use centralized keys
- Prevents key drift and typo regressions across the frontend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:48:28 +02:00

445 lines
13 KiB
TypeScript

/**
* 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,
WeatherMoonRegular,
WeatherSunnyRegular,
} from "@fluentui/react-icons";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { useServerStatus } from "../hooks/useServerStatus";
import { useBlocklistStatus } from "../hooks/useBlocklistStatus";
import { useThemeMode } from "../providers/ThemeProvider";
import { STORAGE_KEY_SIDEBAR_COLLAPSED } from "../utils/constants";
// ---------------------------------------------------------------------------
// 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,
},
footerActions: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
themeButton: {
width: "100%",
justifyContent: "flex-start",
},
logoutButton: {
width: "100%",
justifyContent: "flex-start",
},
sidebarButtonCollapsed: {
justifyContent: "center",
},
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: <GridRegular />, end: true },
{ label: "World Map", to: "/map", icon: <MapRegular /> },
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
{ label: "History", to: "/history", icon: <HistoryRegular /> },
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Main application shell with sidebar navigation and content area.
*
* Renders child routes via `<Outlet />`. 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 { colorMode, toggleColorMode } = useThemeMode();
const navigate = useNavigate();
const readSavedCollapsed = (): boolean => {
try {
const savedValue = localStorage.getItem(STORAGE_KEY_SIDEBAR_COLLAPSED);
if (savedValue === "true") {
return true;
}
if (savedValue === "false") {
return false;
}
} catch {
// Ignore storage errors and fall back to viewport heuristics.
}
return window.innerWidth < 640;
};
const [collapsed, setCollapsed] = useState<boolean>(readSavedCollapsed);
const { status } = useServerStatus();
const { hasErrors: blocklistHasErrors } = useBlocklistStatus();
/** True only after the first successful poll and fail2ban is unreachable. */
const serverOffline = status !== null && !status.online;
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY_SIDEBAR_COLLAPSED, String(collapsed));
} catch {
// Local storage may be unavailable in some environments.
}
}, [collapsed]);
useEffect(() => {
const savedValue = localStorage.getItem(STORAGE_KEY_SIDEBAR_COLLAPSED);
if (savedValue !== null) {
return undefined;
}
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 (
<div className={styles.root}>
{/* ---------------------------------------------------------------- */}
{/* Sidebar */}
{/* ---------------------------------------------------------------- */}
<nav
className={mergeClasses(
styles.sidebar,
collapsed && styles.sidebarCollapsed,
)}
aria-label="Main navigation"
>
{/* Header */}
<div className={styles.sidebarHeader}>
{!collapsed && <span className={styles.logo}>BanGUI</span>}
<Tooltip content={collapsed ? "Expand sidebar" : "Collapse sidebar"} relationship="label">
<Button
appearance="subtle"
icon={<NavigationRegular />}
onClick={toggleCollapse}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
/>
</Tooltip>
</div>
{/* Nav links */}
<ul className={styles.navList} role="list" aria-label="Pages">
{NAV_ITEMS.map((item) => {
const showBadge = item.to === "/blocklists" && blocklistHasErrors;
return (
<li key={item.to} role="listitem">
<Tooltip
content={collapsed ? item.label : ""}
relationship="label"
positioning="after"
>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
mergeClasses(
styles.navLink,
isActive && styles.navLinkActive,
)
}
aria-label={collapsed ? item.label : undefined}
>
<span className={styles.navLinkContent}>
{item.icon}
{!collapsed && (
<Text className={styles.navLabel}>{item.label}</Text>
)}
</span>
{showBadge && (
<Badge
appearance="filled"
color="warning"
size="extra-small"
aria-label="Import errors"
/>
)}
</NavLink>
</Tooltip>
</li>
);
})}
</ul>
{/* Footer — Theme toggle and logout */}
<div className={styles.sidebarFooter}>
{!collapsed && (
<Text className={styles.versionText}>
BanGUI
</Text>
)}
<div className={styles.footerActions}>
<Tooltip
content={collapsed ? (colorMode === "dark" ? "Switch to light mode" : "Switch to dark mode") : ""}
relationship="label"
positioning="after"
>
<Button
appearance="subtle"
icon={colorMode === "dark" ? <WeatherSunnyRegular /> : <WeatherMoonRegular />}
onClick={toggleColorMode}
aria-label={colorMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className={mergeClasses(
styles.themeButton,
collapsed && styles.sidebarButtonCollapsed,
)}
>
{!collapsed && (colorMode === "dark" ? "Light mode" : "Dark mode")}
</Button>
</Tooltip>
<Tooltip
content={collapsed ? "Sign out" : ""}
relationship="label"
positioning="after"
>
<Button
appearance="subtle"
icon={<SignOutRegular />}
onClick={() => void handleLogout()}
aria-label="Sign out"
className={mergeClasses(
styles.logoutButton,
collapsed && styles.sidebarButtonCollapsed,
)}
>
{!collapsed && "Sign out"}
</Button>
</Tooltip>
</div>
</div>
</nav>
{/* ---------------------------------------------------------------- */}
{/* Main content */}
{/* ---------------------------------------------------------------- */}
<main className={styles.main}>
{/* Connection health warning — shown when fail2ban is unreachable */}
{serverOffline && (
<div className={styles.warningBar} role="alert">
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>fail2ban Unreachable</MessageBarTitle>
The connection to the fail2ban server has been lost. Some
features may be temporarily unavailable.
</MessageBarBody>
</MessageBar>
</div>
)}
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
{blocklistHasErrors && (
<div className={styles.warningBar} role="alert">
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>Blocklist Import Errors</MessageBarTitle>
The most recent blocklist import encountered errors. Check the
Blocklists page for details.
</MessageBarBody>
</MessageBar>
</div>
)}
<div className={styles.content}>
<Outlet />
</div>
</main>
</div>
);
}