mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 16:00:51 +08:00
Align network/list shells and audit header with industrial panels; allow permanently deleting revoked API keys. Co-authored-by: Cursor <cursoragent@cursor.com>
128 lines
4.7 KiB
TypeScript
128 lines
4.7 KiB
TypeScript
import { Accordion } from "@heroui/react";
|
|
import type { Key } from "@react-types/shared";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
|
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
|
|
import { useI18n } from "../../i18n";
|
|
import { prefetchCutoverPages } from "./cutoverPrefetch";
|
|
|
|
function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean {
|
|
const group = NETWORK_NAV.find((g) => g.id === groupId);
|
|
if (!group) return false;
|
|
return group.items.some(
|
|
(item) => pathname === item.path || pathname.startsWith(`${item.path}/`),
|
|
);
|
|
}
|
|
|
|
function groupsForPath(pathname: string): Set<Key> {
|
|
const keys = new Set<Key>();
|
|
for (const g of NETWORK_NAV) {
|
|
if (groupContainsPath(g.id, pathname)) keys.add(g.id);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
export function NetworkLayout() {
|
|
const { t } = useI18n();
|
|
const { pathname } = useLocation();
|
|
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(() => groupsForPath(pathname));
|
|
|
|
useEffect(() => {
|
|
setExpandedKeys((prev) => {
|
|
const routeKeys = groupsForPath(pathname);
|
|
if ([...routeKeys].every((k) => prev.has(k))) return prev;
|
|
const next = new Set(prev);
|
|
for (const k of routeKeys) next.add(k);
|
|
return next;
|
|
});
|
|
}, [pathname]);
|
|
|
|
// Warm cutover page chunks as soon as that nav group is open / in use.
|
|
useEffect(() => {
|
|
if (pathname.startsWith("/network/cutover") || expandedKeys.has("cutoverCompare")) {
|
|
prefetchCutoverPages();
|
|
}
|
|
}, [pathname, expandedKeys]);
|
|
|
|
const activeGroupIds = useMemo(() => {
|
|
const ids = new Set<NetworkNavGroupId>();
|
|
for (const g of NETWORK_NAV) {
|
|
if (groupContainsPath(g.id, pathname)) ids.add(g.id);
|
|
}
|
|
return ids;
|
|
}, [pathname]);
|
|
|
|
return (
|
|
<div className="network-shell">
|
|
<aside className="network-nav" aria-label={t("network.title")}>
|
|
<nav className="network-nav__scroll">
|
|
<Accordion
|
|
className="network-nav__accordion"
|
|
hideSeparator
|
|
allowsMultipleExpanded
|
|
expandedKeys={expandedKeys}
|
|
onExpandedChange={(keys) => {
|
|
setExpandedKeys(keys as Set<Key>);
|
|
if ((keys as Set<Key>).has("cutoverCompare")) {
|
|
prefetchCutoverPages();
|
|
}
|
|
}}
|
|
>
|
|
{NETWORK_NAV.map((group) => {
|
|
const activeGroup = activeGroupIds.has(group.id);
|
|
return (
|
|
<Accordion.Item
|
|
key={group.id}
|
|
id={group.id}
|
|
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
|
|
>
|
|
<Accordion.Heading>
|
|
<Accordion.Trigger
|
|
className="network-nav__group-toggle"
|
|
onHoverStart={
|
|
group.id === "cutoverCompare" ? () => prefetchCutoverPages() : undefined
|
|
}
|
|
>
|
|
<span className="network-nav__group-label">{t(group.labelKey)}</span>
|
|
<Accordion.Indicator className="network-nav__chevron" />
|
|
</Accordion.Trigger>
|
|
</Accordion.Heading>
|
|
<Accordion.Panel>
|
|
<Accordion.Body className="network-nav__panel-body">
|
|
<ul className="network-nav__list">
|
|
{group.items.map((item) => (
|
|
<li key={item.id}>
|
|
<NavLink
|
|
to={item.path}
|
|
className={({ isActive }) =>
|
|
`network-nav__link${isActive ? " is-active" : ""}`
|
|
}
|
|
end={
|
|
item.path === "/network/devices" ||
|
|
item.path === "/network/tasks/port-traffic"
|
|
}
|
|
onMouseEnter={
|
|
group.id === "cutoverCompare"
|
|
? () => prefetchCutoverPages()
|
|
: undefined
|
|
}
|
|
>
|
|
{t(item.labelKey)}
|
|
</NavLink>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Accordion.Body>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
</nav>
|
|
</aside>
|
|
<div className="network-main">
|
|
<Outlet />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|