Merge pull request #924 from bbassie/feature/sidebar-layout

Rework site layout into a fixed sidebar
This commit is contained in:
Daniel authored and GitHub committed 2026-06-26 10:06:23 +02:00
commit ea5c35198f
2 files changed
+113 -128

No files matched your search

+90 -49
View File
@@ -1,25 +1,27 @@
import React, { Fragment, useContext, useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Button, Dropdown, Layout, Menu, MenuProps, Tooltip } from "antd";
import { UserOutlined, DownloadOutlined } from "@ant-design/icons";
import { Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, MenuProps, Tooltip, Typography } from "antd";
import { UserOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import ChangeLogModal from "./ChangeLogModal";
import AboutModal from "./AboutModal";
import Link from "./Link";
import { pages } from "../pages";
import { saveJson } from "../util/save_file";
import { useAccount } from "../model/account";
import { DraggingContext } from "../model/is_dragging";
import webUiPackage from "../../package.json";
import logo from "../images/logo.png";
import { ControlConfig } from "@clusterio/lib";
type MenuItem = Required<MenuProps>["items"][number];
const { Header, Sider } = Layout;
const { Sider, Header } = Layout;
function isActiveDropzone(element: HTMLElement | null): boolean {
if (!element) {
@@ -42,6 +44,13 @@ function isActiveDropzone(element: HTMLElement | null): boolean {
export default function SiteLayout() {
let navigate = useNavigate();
let [currentSidebarPath, setCurrentSidebarPath] = useState<string | null>(null);
const screens = Grid.useBreakpoint();
// On phone screens the sidebar becomes an overlay drawer instead of a fixed sider.
// `md` matches the previous Sider breakpoint; `screens.md` is undefined until measured,
// so treat the initial render as desktop to avoid flashing the hamburger.
const isMobile = screens.md === false;
const [drawerOpen, setDrawerOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
let account = useAccount();
let plugins = useContext(ControlContext).plugins;
const control = useContext(ControlContext);
@@ -126,7 +135,39 @@ export default function SiteLayout() {
setDragging(dragging + dragChange);
}
// Navigate and dismiss the mobile drawer, matching hamburger menu conventions.
function navigateAndClose(key: string) {
navigate(key);
setDrawerOpen(false);
}
// The header trigger collapses the sider on desktop and toggles the overlay drawer on phones.
function toggleSidebar() {
if (isMobile) {
setDrawerOpen(value => !value);
} else {
setCollapsed(value => !value);
}
}
let sidebarContent = <Menu
theme="dark"
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", overflow: "auto", borderInlineEnd: 0 }}
onClick={({ key }) => navigateAndClose(key)}
items={menuItems}
/>;
let accountMenu = <Dropdown menu={accountMenuProps} trigger={["click"]} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} style={{ marginInlineStart: "auto" }}>
{account.name}
</Button>
</Dropdown>;
return <Layout
className="site-layout"
style={{ minHeight: "100vh" }}
onDragEnter={() => setDraggingProxy(1)}
onDragLeave={() => setDraggingProxy(-1)}
@@ -169,56 +210,56 @@ export default function SiteLayout() {
setAboutOpen(wasAboutOpen);
}}
/>
<DraggingContext.Provider value={dragging > 0}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<Tooltip
title="View changelog"
placement="bottom"
align={{ offset: [0, -10] }}
>
<span
className="site-version"
style={{ cursor: "pointer" }}
<Header className="site-layout-header">
<Button
type="text"
icon={<MenuOutlined />}
onClick={toggleSidebar}
aria-label="Toggle navigation menu"
/>
<Link to="/" style={{ display: "flex", alignItems: "center" }}>
<img src={logo} width={56} height={56} alt="Clusterio logo" />
</Link>
<Flex vertical justify="center">
<Typography.Title level={4} style={{ margin: 0, lineHeight: 1.2 }}>Clusterio</Typography.Title>
<Tooltip title="View changelog" placement="right">
<Typography.Text
type="danger"
style={{ cursor: "pointer", lineHeight: 1.2 }}
onClick={() => setChangeLogOpen(true)}
>
{webUiPackage.version}
</span>
</Typography.Text>
</Tooltip>
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={["1"]}
items={[{ label: "Dashboard", key: "1" }]}
/>
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
trigger={["click"]}
menu={accountMenuProps}
>
<UserOutlined />
</Dropdown>
</Header>
<Layout className="site-layout">
<Sider
collapsible
collapsedWidth={0}
breakpoint="md"
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
width={250}
className="site-layout-sider"
>
<Menu
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => navigate(key)}
items={menuItems}
/>
</Sider>
</Flex>
{accountMenu}
</Header>
<DraggingContext.Provider value={dragging > 0}>
<Layout hasSider={!isMobile}>
{isMobile
? <Drawer
placement="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={250}
closable={false}
// Start below the sticky 64px header so it stays visible and usable.
rootStyle={{ top: 64 }}
styles={{ body: { padding: 0, background: "#001529" } }}
>
{sidebarContent}
</Drawer>
: <Sider
width={250}
collapsible
collapsed={collapsed}
collapsedWidth={0}
trigger={null}
className="site-layout-sider"
>
{sidebarContent}
</Sider>
}
<Layout className="site-layout-content-container">
<Routes>
{combinedPages.map(({ path, sidebarPath, content }) => <Route
+23 -79
View File
@@ -33,17 +33,13 @@ body {
}
.site-breadcrumb {
margin: 16px 0 16px 52px;
margin: 16px 0;
}
@media (min-width: 576px) {
.site-layout-content-container {
padding: 0 24px 24px;
}
.site-breadcrumb {
margin-left: 28px;
}
}
@media (min-width: 768px) {
@@ -56,13 +52,30 @@ body {
}
}
.site-layout-sider {
z-index: 0;
/* Top bar hosting the menu toggle and branding, sticky above the content. */
.site-layout-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
padding-inline: 16px;
background: #001529;
}
.header {
padding-left: 250px;
padding-right: 64px;
/* Keep the sidebar fixed in place (below the header) while content scrolls. */
.site-layout-sider {
position: sticky;
top: 64px;
height: calc(100vh - 64px);
align-self: flex-start;
}
/* Snap the sider open/closed instead of animating its width — the width
transition reflowed the content area and caused styling glitches. */
.ant-layout-sider.site-layout-sider {
transition: none;
}
.page-header {
@@ -105,75 +118,6 @@ body {
margin-left: auto;
}
.site-name {
position: absolute;
top: -10px;
left: 112px;
font-size: 18pt;
font-weight: 400;
}
.site-version {
position: absolute;
top: 10px;
left: 120px;
color: #f00;
}
.site-logo {
position: absolute;
left: 36px;
width: 64px;
height: 64px;
background-image: url("./images/logo.png");
background-size: 64px;
}
@media not all and (min-width: 576px) {
.header {
padding-left: 88px;
}
.site-logo {
left: 12px;
}
.site-name {
display: none;
}
.site-version {
display: none;
}
}
.account-dropdown-header {
position: absolute;
right: 0;
top: 0;
width: 64px;
height: 64px;
font-size: 18px;
line-height: 72px;
display: flex;
justify-content: center;
}
.account-dropdown-header::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: transparent;
transition: all 0.3s;
content: "";
}
.account-dropdown-header:hover::after {
background: rgba(255, 255, 255, 0.1);
}
.error-traceback code {
display: block;
margin: 0;