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

322 lines
9.9 KiB
TypeScript

import { Link, useLocation } from "react-router";
import {
LayoutDashboard,
Radio,
Library,
LayoutPanelTop,
PanelsTopLeft,
Users,
MonitorSmartphone,
History,
Captions,
Download,
SlidersHorizontal,
Server,
Bot,
ArrowLeft,
Wrench,
KeyRound,
CalendarClock,
ScrollText,
Blocks,
Puzzle,
Send,
} from "lucide-react";
import type { ReactNode } from "react";
import { SiloBrand } from "@/components/SiloBrand";
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";
import { pluginRouteHref } from "@/lib/pluginRouteHref";
interface SidebarItem {
label: string;
icon: ReactNode;
href: string;
exact?: boolean;
badge?: ReactNode;
// external=true means render as <a> (full page navigation) instead of
// react-router <Link>. Used for plugin routes mounted at /api/v1/plugins/...
external?: boolean;
}
interface SidebarSection {
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();
let buildDisplay = "unavailable";
if (buildInfo.isPending && !buildInfo.data) {
buildDisplay = "loading...";
} else if (buildInfo.isError) {
buildDisplay = "load failed";
} else if (buildInfo.data?.available) {
buildDisplay = buildInfo.data.display;
}
const sections: SidebarSection[] = [
{
label: "Overview",
items: [
{
label: "Dashboard",
icon: <LayoutDashboard className="h-[18px] w-[18px]" />,
href: "/admin",
exact: true,
},
{
label: "Activity",
icon: <Radio className="h-[18px] w-[18px]" />,
href: "/admin/activity",
badge:
sessionCount > 0 ? <span className="live-badge">{sessionCount} live</span> : undefined,
},
{
label: "Logs",
icon: <ScrollText className="h-[18px] w-[18px]" />,
href: "/admin/logs",
},
],
},
{
label: "Content",
items: [
{
label: "Libraries",
icon: <Library className="h-[18px] w-[18px]" />,
href: "/admin/libraries",
},
{
label: "Collections",
icon: <LayoutPanelTop className="h-[18px] w-[18px]" />,
href: "/admin/collections",
},
{
label: "Requests",
icon: <Send className="h-[18px] w-[18px]" />,
href: "/admin/requests",
},
{
label: "Sections",
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
href: "/admin/sections",
},
{
label: "Subtitles",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/subtitles",
},
],
},
{
label: "Users",
items: [
{
label: "Users",
icon: <Users className="h-[18px] w-[18px]" />,
href: "/admin/users",
},
{
label: "Devices",
icon: <MonitorSmartphone className="h-[18px] w-[18px]" />,
href: "/admin/devices",
},
{
label: "Playback History",
icon: <History className="h-[18px] w-[18px]" />,
href: "/admin/history",
},
{
label: "History Import",
icon: <Download className="h-[18px] w-[18px]" />,
href: "/admin/history-import",
},
],
},
{
label: "Server",
items: [
{
label: "Scheduled Tasks",
icon: <CalendarClock className="h-[18px] w-[18px]" />,
href: "/admin/tasks",
},
{
label: "Nodes",
icon: <Server className="h-[18px] w-[18px]" />,
href: "/admin/nodes",
},
{
label: "Maintenance",
icon: <Wrench className="h-[18px] w-[18px]" />,
href: "/admin/maintenance",
},
{
label: "Plugins",
icon: <Blocks className="h-[18px] w-[18px]" />,
href: "/admin/plugins",
},
{
label: "Settings",
icon: <SlidersHorizontal className="h-[18px] w-[18px]" />,
href: "/admin/settings",
},
{
label: "Recommendations",
icon: <Bot className="h-[18px] w-[18px]" />,
href: "/admin/recommendations",
},
{
label: "API Keys",
icon: <KeyRound className="h-[18px] w-[18px]" />,
href: "/admin/api-keys",
},
],
},
];
// 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: SidebarItem[] = [];
for (const inst of adminInstallations ?? []) {
if (!inst.enabled) continue;
for (const route of inst.routes ?? []) {
if (!route.navigable || route.navigation_kind !== "admin") continue;
adminPluginItems.push({
label: route.navigation_label || inst.plugin_id,
icon: <Puzzle className="h-[18px] w-[18px]" />,
href: pluginRouteHref(inst.id, route.path),
external: true,
});
}
}
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-1">
<SiloBrand className="h-12 w-[112px]" />
</div>
{/* Nav sections */}
<nav
aria-label="Admin navigation"
className="sidebar-scroll flex-1 space-y-5 overflow-y-auto px-3"
>
{sections.map((section) => (
<div
key={section.label}
role="group"
aria-labelledby={`admin-nav-${section.label.toLowerCase()}`}
>
<h3
id={`admin-nav-${section.label.toLowerCase()}`}
className="text-muted-foreground mb-2 px-2 text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{section.label}
</h3>
<ul className="list-none space-y-0.5">
{section.items.map((item) => {
const active = isActive(item);
const className = `relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-[13px] font-medium transition-colors duration-150 ${
active
? "text-primary bg-accent"
: "text-muted-foreground hover:text-foreground hover:bg-accent/70"
} `;
const inner = (
<>
{active && (
<span
className="absolute top-1/2 left-[-12px] h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<span className="flex w-[18px] flex-shrink-0 items-center justify-center">
{item.icon}
</span>
<span>{item.label}</span>
{item.badge && <span className="ml-auto">{item.badge}</span>}
</>
);
return (
<li key={item.href}>
{item.external ? (
<a
href={item.href}
onClick={(e) => {
e.preventDefault();
void navigateToPluginRoute(item.href);
onNavigate?.();
}}
aria-current={active ? "page" : undefined}
className={className}
>
{inner}
</a>
) : (
<Link
to={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={className}
>
{inner}
</Link>
)}
</li>
);
})}
</ul>
</div>
))}
</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>
);
}