Group the sidebar into collapsible submenus
The flat 22-item menu becomes 7 entries: Dashboard and Secrets as plain links, and five groups. - Infrastructure: Servers, Proxmox, Synology, Docker, Tailscale - Network: DNS, Domains, IP Addresses, Consistency - Automation: Semaphore, Gitea - Operations: Maintenance, Generator - Administration: Integrations, Users, Sessions, Audit Log, Diagnostic Log, Settings Privacy moves to the sidebar footer beside the theme toggle and sign-out, since it's about the person rather than the homelab. Behaviour: - Groups open on demand. The group holding the current page is always open (deep routes count: /servers/7 keeps Servers active), and its header stays emphasised even if you close it. Which groups you leave open is remembered in the browser, and the menu still works if storage is blocked. - Role handling: pages a role can't use are dropped, an emptied group disappears, and a group reduced to a single page is drawn as that page's own link. A viewer therefore sees a plain Integrations link where an admin sees Administration, and an operator sees Administration with Integrations and Audit Log. - Toggling a group on mobile keeps the menu open; choosing a page closes it, as before. - Group headers are real buttons with aria-expanded. Front end only: one component, plus a small CSS rule tightening submenu rows so several open groups still fit on one screen. Routes, permissions and backend are unchanged. Verified in a browser with the real shell as admin, operator and viewer: structure per role, open/close and aria state, active highlighting on direct and deep routes, auto-open of the current page's group, state surviving a reload, and the mobile toggler behaviour. Not done: the attention dots on closed groups (deliberately held for a later step). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9229ea2ed6
commit
ada2e648e9
3 files changed
+186
-41
No files matched your search
+179
-40
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import {
|
||||
IconLayoutDashboard,
|
||||
IconServer2,
|
||||
@@ -26,46 +26,127 @@ import {
|
||||
IconWorldWww,
|
||||
IconListCheck,
|
||||
IconShieldLock,
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
|
||||
import { formatRemaining } from "../utils/duration";
|
||||
import { getTheme, setTheme, type Theme } from "../utils/theme";
|
||||
import CommandPalette from "../components/CommandPalette";
|
||||
|
||||
interface NavItem {
|
||||
type Role = "operator" | "admin";
|
||||
|
||||
interface NavLeaf {
|
||||
to: string;
|
||||
label: string;
|
||||
/** Shown for top-level links, and when a group is left with a single visible page and so is drawn as that page's own link. */
|
||||
icon: ReactNode;
|
||||
minRole?: "operator" | "admin";
|
||||
minRole?: Role;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
interface NavGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
items: NavLeaf[];
|
||||
}
|
||||
|
||||
type NavEntry = NavLeaf | NavGroup;
|
||||
|
||||
const isGroup = (entry: NavEntry): entry is NavGroup => "items" in entry;
|
||||
|
||||
const NAV: NavEntry[] = [
|
||||
{ to: "/", label: "Dashboard", icon: <IconLayoutDashboard size={20} /> },
|
||||
{ to: "/servers", label: "Servers", icon: <IconServer2 size={20} /> },
|
||||
{ to: "/proxmox", label: "Proxmox", icon: <IconServerCog size={20} /> },
|
||||
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
||||
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
||||
{
|
||||
id: "infrastructure",
|
||||
label: "Infrastructure",
|
||||
icon: <IconServer2 size={20} />,
|
||||
items: [
|
||||
{ to: "/servers", label: "Servers", icon: <IconServer2 size={20} /> },
|
||||
{ to: "/proxmox", label: "Proxmox", icon: <IconServerCog size={20} /> },
|
||||
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
||||
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
|
||||
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
icon: <IconWorld size={20} />,
|
||||
items: [
|
||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
|
||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automation",
|
||||
icon: <IconRefresh size={20} />,
|
||||
items: [
|
||||
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
|
||||
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
|
||||
],
|
||||
},
|
||||
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||
{ to: "/consistency", label: "Consistency", icon: <IconListCheck size={20} /> },
|
||||
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
|
||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
|
||||
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
|
||||
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
|
||||
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
|
||||
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
|
||||
{ to: "/privacy", label: "Privacy", icon: <IconShieldLock size={20} /> },
|
||||
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
||||
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
|
||||
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
||||
{ to: "/diag-log", label: "Diagnostic Log", icon: <IconStethoscope size={20} />, minRole: "admin" },
|
||||
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
||||
{
|
||||
id: "operations",
|
||||
label: "Operations",
|
||||
icon: <IconTool size={20} />,
|
||||
items: [
|
||||
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
|
||||
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "administration",
|
||||
label: "Administration",
|
||||
icon: <IconSettings size={20} />,
|
||||
items: [
|
||||
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
|
||||
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
||||
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
|
||||
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
||||
{ to: "/diag-log", label: "Diagnostic Log", icon: <IconStethoscope size={20} />, minRole: "admin" },
|
||||
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const roleRank: Record<CurrentUser["role"], number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
|
||||
/** What this role can see: pages it isn't allowed are dropped, an emptied group disappears, and a group down to one page becomes that page's own link. */
|
||||
function visibleNav(role: CurrentUser["role"]): NavEntry[] {
|
||||
const allowed = (leaf: NavLeaf) => !leaf.minRole || roleRank[role] >= roleRank[leaf.minRole];
|
||||
const out: NavEntry[] = [];
|
||||
for (const entry of NAV) {
|
||||
if (!isGroup(entry)) {
|
||||
if (allowed(entry)) out.push(entry);
|
||||
continue;
|
||||
}
|
||||
const items = entry.items.filter(allowed);
|
||||
if (items.length === 1) out.push(items[0]);
|
||||
else if (items.length > 1) out.push({ ...entry, items });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const OPEN_GROUPS_KEY = "hlm-nav-open-groups";
|
||||
|
||||
function loadOpenGroups(): Set<string> {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(OPEN_GROUPS_KEY) ?? "[]");
|
||||
return new Set(Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []);
|
||||
} catch {
|
||||
return new Set(); // storage blocked or corrupted: start with everything closed
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this the current page (or a page beneath it, like /servers/3 under /servers)? */
|
||||
function isCurrent(pathname: string, to: string): boolean {
|
||||
return to === "/" ? pathname === "/" : pathname === to || pathname.startsWith(`${to}/`);
|
||||
}
|
||||
|
||||
export default function AppShell({ user, children }: { user: CurrentUser; children: ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [theme, setThemeState] = useState<Theme>(() => getTheme());
|
||||
@@ -91,9 +172,33 @@ export default function AppShell({ user, children }: { user: CurrentUser; childr
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.minRole || roleRank[user.role] >= roleRank[item.minRole],
|
||||
);
|
||||
const nav = visibleNav(user.role);
|
||||
const { pathname } = useLocation();
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(loadOpenGroups);
|
||||
|
||||
// The group holding the current page is always open, so you can see where you are (others stay as you left them).
|
||||
useEffect(() => {
|
||||
const active = nav.find((entry): entry is NavGroup => isGroup(entry) && entry.items.some((item) => isCurrent(pathname, item.to)));
|
||||
if (active) setOpenGroups((prev) => (prev.has(active.id) ? prev : new Set(prev).add(active.id)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, user.role]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(OPEN_GROUPS_KEY, JSON.stringify([...openGroups]));
|
||||
} catch {
|
||||
// not persisted; the menu still works for this visit
|
||||
}
|
||||
}, [openGroups]);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||
@@ -121,19 +226,45 @@ export default function AppShell({ user, children }: { user: CurrentUser; childr
|
||||
<CommandPalette />
|
||||
</div>
|
||||
<ul className="navbar-nav pt-lg-3">
|
||||
{visibleItems.map((item) => (
|
||||
<li className="nav-item" key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className="nav-link"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">{item.icon}</span>
|
||||
<span className="nav-link-title">{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
{nav.map((entry) => {
|
||||
if (!isGroup(entry)) {
|
||||
return (
|
||||
<li className="nav-item" key={entry.to}>
|
||||
<NavLink to={entry.to} end={entry.to === "/"} className="nav-link" onClick={() => setSidebarOpen(false)}>
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">{entry.icon}</span>
|
||||
<span className="nav-link-title">{entry.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const open = openGroups.has(entry.id);
|
||||
const holdsCurrent = entry.items.some((item) => isCurrent(pathname, item.to));
|
||||
return (
|
||||
<li className="nav-item dropdown" key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link dropdown-toggle w-100 border-0 bg-transparent text-start ${open ? "show" : ""} ${holdsCurrent ? "active" : ""}`}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleGroup(entry.id)}
|
||||
>
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">{entry.icon}</span>
|
||||
<span className="nav-link-title">{entry.label}</span>
|
||||
</button>
|
||||
<div className={`dropdown-menu ${open ? "show" : ""}`}>
|
||||
{entry.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `dropdown-item ${isActive ? "active" : ""}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="navbar-footer px-3">
|
||||
<button
|
||||
@@ -144,6 +275,14 @@ export default function AppShell({ user, children }: { user: CurrentUser; childr
|
||||
{theme === "dark" ? <IconSun size={16} /> : <IconMoon size={16} />}
|
||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
</button>
|
||||
<NavLink
|
||||
to="/privacy"
|
||||
className="btn btn-outline-secondary btn-sm w-100 mb-2 d-flex align-items-center justify-content-center gap-2"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<IconShieldLock size={16} />
|
||||
Privacy
|
||||
</NavLink>
|
||||
<div className="text-secondary small mb-2">
|
||||
Signed in as {user.name ?? user.email ?? user.sub} · {user.role}
|
||||
</div>
|
||||
|
||||
@@ -33,3 +33,9 @@
|
||||
color: var(--tblr-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar submenus: a little tighter than Tabler's dropdown rows, so several open groups still fit on one screen. */
|
||||
.navbar-vertical .dropdown-menu .dropdown-item {
|
||||
padding-top: 0.3rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
Reference in new issue
Block a user