feat: Stage 3 — application shell and navigation
This commit is contained in:
279
frontend/src/layouts/MainLayout.tsx
Normal file
279
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
// Main content
|
||||
main: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
},
|
||||
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: "Configuration", to: "/config", icon: <SettingsRegular /> },
|
||||
{ label: "History", to: "/history", icon: <HistoryRegular /> },
|
||||
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
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) => (
|
||||
<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}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<Text className={styles.navLabel}>{item.label}</Text>
|
||||
)}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Footer — Logout */}
|
||||
<div className={styles.sidebarFooter}>
|
||||
<Tooltip
|
||||
content={collapsed ? "Sign out" : ""}
|
||||
relationship="label"
|
||||
positioning="after"
|
||||
>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<SignOutRegular />}
|
||||
onClick={() => void handleLogout()}
|
||||
aria-label="Sign out"
|
||||
style={{ width: "100%", justifyContent: collapsed ? "center" : "flex-start" }}
|
||||
>
|
||||
{!collapsed && "Sign out"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Main content */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<main className={styles.main}>
|
||||
<div className={styles.content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user