Files
silo-server/web/src/components/AdminSidebar.tsx
T

151 lines
5.4 KiB
TypeScript

import { Link, useLocation } from "react-router";
import { ArrowLeft } from "lucide-react";
import type { ReactNode } from "react";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { SiloBrand } from "@/components/SiloBrand";
import {
ADMIN_NAV_SECTIONS,
buildAdminPluginNavItems,
type AdminNavGroup,
type AdminNavItem,
} from "@/lib/adminNavigation";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins";
import { useAdminSessions } from "@/hooks/queries/admin/stats";
import { useBuildInfo } from "@/hooks/queries/admin/system";
interface SidebarItem extends AdminNavItem {
badge?: ReactNode;
}
interface SidebarSection extends Omit<AdminNavGroup, "items"> {
label: string;
items: SidebarItem[];
}
interface AdminSidebarProps {
onNavigate?: () => void;
}
function useSessionCount() {
const { data: sessions = [] } = useAdminSessions();
return sessions.length;
}
export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
const location = useLocation();
const sessionCount = useSessionCount();
const buildInfo = useBuildInfo();
// Falls back to "dev build" when the binary carries no VCS/ldflags revision
// (e.g. `go run` or an image built without BUILD_REVISION) rather than a stark
// "unavailable".
let buildDisplay = "dev build";
if (buildInfo.isPending && !buildInfo.data) {
buildDisplay = "loading...";
} else if (buildInfo.isError) {
buildDisplay = "load failed";
} else if (buildInfo.data?.available) {
buildDisplay = buildInfo.data.display;
}
const activityBadge =
sessionCount > 0 ? <span className="live-badge">{sessionCount} live</span> : undefined;
const sections: SidebarSection[] = ADMIN_NAV_SECTIONS.map((section) => ({
...section,
items: section.items.map((item) =>
item.href === "/admin/activity" ? { ...item, badge: activityBadge } : item,
),
}));
// Use the admin installations endpoint, not /settings/plugins — the user
// settings endpoint filters to plugins that expose user settings / a user-
// navigable route, which excludes admin-only plugins like arrproxy and
// arrouter. The admin sidebar needs the full installation list to render
// its "Plugin Apps" group.
const { data: adminInstallations } = useAdminPluginInstallations();
const adminPluginItems = buildAdminPluginNavItems(adminInstallations);
if (adminPluginItems.length > 0) {
sections.push({ label: "Plugin Apps", items: adminPluginItems });
}
function isActive(item: SidebarItem) {
if (item.exact) return location.pathname === item.href;
return location.pathname === item.href || location.pathname.startsWith(`${item.href}/`);
}
return (
<aside className="border-sidebar-border/70 bg-sidebar/92 fixed top-0 bottom-0 left-0 z-40 flex w-[240px] flex-col border-r backdrop-blur-2xl">
{/* Logo */}
<div className="flex items-center gap-2.5 px-5 pt-6 pb-4">
<Link
to="/"
onClick={onNavigate}
aria-label="Go to app home"
className="focus-visible:ring-ring/50 inline-flex rounded-md transition-opacity hover:opacity-85 focus-visible:ring-[3px] focus-visible:outline-none"
>
<SiloBrand className="h-12 w-[112px]" />
</Link>
</div>
{/* Nav sections */}
<nav
aria-label="Admin navigation"
className="sidebar-scroll flex-1 space-y-5 overflow-y-auto px-3"
>
{sections.map((section) => (
<SideNavSection key={section.label} label={section.label} idPrefix="admin-nav">
{section.items.map((item) =>
item.external ? (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
external
active={isActive(item)}
badge={item.badge}
onClick={(e) => {
e.preventDefault();
void navigateToPluginRoute(item.href);
onNavigate?.();
}}
/>
) : (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
active={isActive(item)}
badge={item.badge}
onClick={onNavigate}
/>
),
)}
</SideNavSection>
))}
</nav>
{/* Footer */}
<div className="space-y-3 px-3 pb-4">
<div className="border-sidebar-border/70 bg-sidebar-accent/40 rounded-xl border px-3 py-2">
<div className="text-muted-foreground text-[10px] font-semibold tracking-[0.18em] uppercase">
Build
</div>
<div className="text-sidebar-foreground mt-1 font-mono text-[12px] leading-5">
{buildDisplay}
</div>
</div>
{/* Back to app */}
<Link
to="/"
onClick={onNavigate}
className="text-muted-foreground hover:text-foreground hover:bg-accent/70 flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-[13px] font-medium transition-colors duration-150"
>
<ArrowLeft className="h-[18px] w-[18px]" />
<span>Back to App</span>
</Link>
</div>
</aside>
);
}