feat(web): regroup admin navigation and settings into intuitive sections

Reorganize the admin sidebar into Overview / Content / Automation /
Users / System so media pipeline tools no longer hide under Users and
Settings is not buried in a seven-item Server grab-bag. Group the flat
13-tab admin settings page into Server / Media / Connections / Data
sections, and split the Integrations tab into dedicated Subtitles
(search providers + AI translation) and Watch Providers (Trakt/Simkl
OAuth) pages, leaving Integrations for one-off keys like MDBList. All
routes and ?tab= ids are unchanged so deep links keep working.

Extract the shared grouped-rail markup used by the admin sidebar, admin
settings nav, and user settings nav into a SideNav component, and
remove the orphaned admin-settings PluginsSettings page (superseded by
/admin/plugins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-09 14:23:10 -04:00
co-authored by Claude Fable 5
parent 2c714e4ef2
commit 89f005dfa8
13 changed files with 878 additions and 1449 deletions
+13 -5
View File
@@ -54,32 +54,40 @@ function renderSidebar() {
}
describe("AdminSidebar", () => {
it("includes a Sections link in the manage navigation", () => {
it("renders the grouped navigation sections", () => {
const markup = renderSidebar();
for (const section of ["Overview", "Content", "Automation", "Users", "System"]) {
expect(markup).toContain(`>${section}<`);
}
});
it("includes a Sections link in the content navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/sections"');
expect(markup).toContain(">Sections<");
});
it("includes a Maintenance link in the server navigation", () => {
it("includes a Maintenance link in the system navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/maintenance"');
expect(markup).toContain(">Maintenance<");
});
it("includes a Recommendations link in the server navigation", () => {
it("includes a Recommendations link in the automation navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/recommendations"');
expect(markup).toContain(">Recommendations<");
});
it("includes a Marker History link in the users navigation", () => {
it("includes a Markers link in the automation navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/marker-history"');
expect(markup).toContain(">Marker History<");
expect(markup).toContain(">Markers<");
});
it("renders the build identifier in the footer", () => {
+65 -169
View File
@@ -22,8 +22,11 @@ import {
Puzzle,
Send,
RefreshCw,
SkipForward,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { SiloBrand } from "@/components/SiloBrand";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins";
@@ -33,7 +36,7 @@ import { pluginRouteHref } from "@/lib/pluginRouteHref";
interface SidebarItem {
label: string;
icon: ReactNode;
icon: LucideIcon;
href: string;
exact?: boolean;
badge?: ReactNode;
@@ -72,133 +75,60 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
buildDisplay = buildInfo.data.display;
}
// Grouped by admin intent: monitoring, curating the catalog, background
// processing the server runs on its own, people and their data, and the
// server installation itself.
const sections: SidebarSection[] = [
{
label: "Overview",
items: [
{
label: "Dashboard",
icon: <LayoutDashboard className="h-[18px] w-[18px]" />,
href: "/admin",
exact: true,
},
{ label: "Dashboard", icon: LayoutDashboard, href: "/admin", exact: true },
{
label: "Activity",
icon: <Radio className="h-[18px] w-[18px]" />,
icon: Radio,
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: "Logs", icon: ScrollText, 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: "Autoscan",
icon: <RefreshCw className="h-[18px] w-[18px]" />,
href: "/admin/autoscan",
},
{
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: "Libraries", icon: Library, href: "/admin/libraries" },
{ label: "Collections", icon: LayoutPanelTop, href: "/admin/collections" },
{ label: "Sections", icon: PanelsTopLeft, href: "/admin/sections" },
{ label: "Requests", icon: Send, href: "/admin/requests" },
],
},
{
label: "Automation",
items: [
{ label: "Autoscan", icon: RefreshCw, href: "/admin/autoscan" },
{ label: "Scheduled Tasks", icon: CalendarClock, href: "/admin/tasks" },
{ label: "Subtitles", icon: Captions, href: "/admin/subtitles" },
{ label: "Markers", icon: SkipForward, href: "/admin/marker-history" },
{ label: "Recommendations", icon: Bot, href: "/admin/recommendations" },
],
},
{
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: "Marker History",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/marker-history",
},
{
label: "History Import",
icon: <Download className="h-[18px] w-[18px]" />,
href: "/admin/history-import",
},
{ label: "Users", icon: Users, href: "/admin/users" },
{ label: "Devices", icon: MonitorSmartphone, href: "/admin/devices" },
{ label: "Playback History", icon: History, href: "/admin/history" },
{ label: "History Import", icon: Download, href: "/admin/history-import" },
],
},
{
label: "Server",
label: "System",
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",
},
{ label: "Settings", icon: SlidersHorizontal, href: "/admin/settings" },
{ label: "Plugins", icon: Blocks, href: "/admin/plugins" },
{ label: "Nodes", icon: Server, href: "/admin/nodes" },
{ label: "API Keys", icon: KeyRound, href: "/admin/api-keys" },
{ label: "Maintenance", icon: Wrench, href: "/admin/maintenance" },
],
},
];
@@ -216,7 +146,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
if (!route.navigable || route.navigation_kind !== "admin") continue;
adminPluginItems.push({
label: route.navigation_label || inst.plugin_id,
icon: <Puzzle className="h-[18px] w-[18px]" />,
icon: Puzzle,
href: pluginRouteHref(inst.id, route.path),
external: true,
});
@@ -250,70 +180,36 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
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>
<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>
+100
View File
@@ -0,0 +1,100 @@
import { Link } from "react-router";
import type { LucideIcon } from "lucide-react";
import type { MouseEvent, ReactNode } from "react";
import { cn } from "@/lib/utils";
// Shared building blocks for the grouped vertical navigation rails used by the
// admin sidebar, the admin settings page, and the user settings page. Keeping
// the markup here means active states, spacing, and section headers stay
// consistent across all three.
interface SideNavSectionProps {
label: string;
/** Prefix for the section heading id, e.g. "admin-nav". */
idPrefix: string;
children: ReactNode;
}
export function SideNavSection({ label, idPrefix, children }: SideNavSectionProps) {
const headingId = `${idPrefix}-${label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div role="group" aria-labelledby={headingId}>
<h3
id={headingId}
className="text-muted-foreground mb-2 px-2 text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{label}
</h3>
<ul className="list-none space-y-0.5">{children}</ul>
</div>
);
}
interface SideNavItemProps {
label: string;
icon: LucideIcon;
active?: boolean;
badge?: ReactNode;
/** Internal route rendered as a react-router <Link>. */
href?: string;
/**
* With href, render a plain <a> (full page navigation) instead of a
* react-router <Link>. Used for plugin routes mounted at /api/v1/plugins/...
*/
external?: boolean;
/** Without href, the item renders as a <button>. */
onClick?: (event: MouseEvent<HTMLElement>) => void;
}
export function SideNavItem({
label,
icon: Icon,
active = false,
badge,
href,
external,
onClick,
}: SideNavItemProps) {
const className = cn(
"relative flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left 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">
<Icon className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 truncate">{label}</span>
{badge && <span className="ml-auto">{badge}</span>}
</>
);
const ariaCurrent = active ? "page" : undefined;
return (
<li>
{href ? (
external ? (
<a href={href} onClick={onClick} aria-current={ariaCurrent} className={className}>
{inner}
</a>
) : (
<Link to={href} onClick={onClick} aria-current={ariaCurrent} className={className}>
{inner}
</Link>
)
) : (
<button type="button" onClick={onClick} aria-current={ariaCurrent} className={className}>
{inner}
</button>
)}
</li>
);
}