103 lines
3.7 KiB
TypeScript
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.
|
|
}
|
|
}
|