diff --git a/README.md b/README.md index 2e1b4c3..5c6ba05 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Authentik (OIDC), with local admin/operator/viewer roles. All modules from the original plan are built: -- Monorepo scaffold, Tabler-themed app shell/navigation +- Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered) - Authentik OIDC login, roles (first user to sign in becomes admin), audit log - **Dashboard** — an overview of every system this app tracks, all sharing one widget-card design (label + status badge, a small stat row, then its diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index fd06df4..6bf2ee5 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -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: }, - { to: "/servers", label: "Servers", icon: }, - { to: "/proxmox", label: "Proxmox", icon: }, - { to: "/docker", label: "Docker", icon: }, - { to: "/synology", label: "Synology", icon: }, + { + id: "infrastructure", + label: "Infrastructure", + icon: , + items: [ + { to: "/servers", label: "Servers", icon: }, + { to: "/proxmox", label: "Proxmox", icon: }, + { to: "/synology", label: "Synology", icon: }, + { to: "/docker", label: "Docker", icon: }, + { to: "/tailscale", label: "Tailscale", icon: }, + ], + }, + { + id: "network", + label: "Network", + icon: , + items: [ + { to: "/dns", label: "DNS", icon: }, + { to: "/domains", label: "Domains", icon: }, + { to: "/ipam", label: "IP Addresses", icon: }, + { to: "/consistency", label: "Consistency", icon: }, + ], + }, + { + id: "automation", + label: "Automation", + icon: , + items: [ + { to: "/semaphore", label: "Semaphore", icon: }, + { to: "/gitea", label: "Gitea", icon: }, + ], + }, { to: "/secrets", label: "Secrets", icon: }, - { to: "/dns", label: "DNS", icon: }, - { to: "/consistency", label: "Consistency", icon: }, - { to: "/domains", label: "Domains", icon: }, - { to: "/ipam", label: "IP Addresses", icon: }, - { to: "/tailscale", label: "Tailscale", icon: }, - { to: "/semaphore", label: "Semaphore", icon: }, - { to: "/gitea", label: "Gitea", icon: }, - { to: "/integrations", label: "Integrations", icon: }, - { to: "/generator", label: "Generator", icon: }, - { to: "/maintenance", label: "Maintenance", icon: }, - { to: "/privacy", label: "Privacy", icon: }, - { to: "/users", label: "Users", icon: , minRole: "admin" }, - { to: "/sessions", label: "Sessions", icon: , minRole: "admin" }, - { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, - { to: "/diag-log", label: "Diagnostic Log", icon: , minRole: "admin" }, - { to: "/settings", label: "Settings", icon: , minRole: "admin" }, + { + id: "operations", + label: "Operations", + icon: , + items: [ + { to: "/maintenance", label: "Maintenance", icon: }, + { to: "/generator", label: "Generator", icon: }, + ], + }, + { + id: "administration", + label: "Administration", + icon: , + items: [ + { to: "/integrations", label: "Integrations", icon: }, + { to: "/users", label: "Users", icon: , minRole: "admin" }, + { to: "/sessions", label: "Sessions", icon: , minRole: "admin" }, + { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, + { to: "/diag-log", label: "Diagnostic Log", icon: , minRole: "admin" }, + { to: "/settings", label: "Settings", icon: , minRole: "admin" }, + ], + }, ]; const roleRank: Record = { 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 { + 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(() => 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>(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
    - {visibleItems.map((item) => ( -
  • - setSidebarOpen(false)} - > - {item.icon} - {item.label} - -
  • - ))} + {nav.map((entry) => { + if (!isGroup(entry)) { + return ( +
  • + setSidebarOpen(false)}> + {entry.icon} + {entry.label} + +
  • + ); + } + const open = openGroups.has(entry.id); + const holdsCurrent = entry.items.some((item) => isCurrent(pathname, item.to)); + return ( +
  • + +
    + {entry.items.map((item) => ( + `dropdown-item ${isActive ? "active" : ""}`} + onClick={() => setSidebarOpen(false)} + > + {item.label} + + ))} +
    +
  • + ); + })}
+ setSidebarOpen(false)} + > + + Privacy +
Signed in as {user.name ?? user.email ?? user.sub} · {user.role}
diff --git a/web/src/styles.css b/web/src/styles.css index 88605ef..422833d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; +}