Files
govoplan-core/webui/src/layout/IconRail.tsx
2026-07-07 16:00:38 +02:00

103 lines
3.7 KiB
TypeScript

import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import type { AuthInfo, PlatformNavItem } from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems]
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
.filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export default function IconRail({
compact = false,
auth = null,
navItems = []
}: {
compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[];
}) {
const location = useLocation();
const items = visibleNavItems(auth, navItems);
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
useEffect(() => {
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
if (!currentRoot || location.pathname === currentRoot) return;
const target = `${location.pathname}${location.search}${location.hash}`;
setRememberedTargets((current) => {
if (current[currentRoot] === target) return current;
const next = { ...current, [currentRoot]: target };
saveRememberedTargets(next);
return next;
});
}, [location.hash, location.pathname, location.search, topLevelItems]);
return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
<div className="brand-mark" title="GovOPlaN">G</div>
{!compact && (
<>
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={label}>
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
</NavLink>
);
})}
</nav>
<div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title="Settings">
<Settings size={20} />
</NavLink>
</div>
</>
)}
</aside>
);
}
function modulePathActive(pathname: string, root: string): boolean {
if (root === "/") return pathname === "/";
return pathname === root || pathname.startsWith(`${root}/`);
}
function loadRememberedTargets(): Record<string, string> {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(MODULE_NAV_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, string] => {
const [root, target] = entry;
return typeof root === "string" && typeof target === "string" && root.startsWith("/") && target.startsWith(`${root}/`);
}),
);
} catch {
return {};
}
}
function saveRememberedTargets(targets: Record<string, string>): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
} catch {
// Remembered navigation is a convenience only.
}
}