diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx
new file mode 100644
index 00000000..5c88a0e9
--- /dev/null
+++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx
@@ -0,0 +1,67 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+
+import AdminSettingsLayout from "./AdminSettingsLayout";
+
+// The layout only needs the active tab's component to render; a loading form
+// keeps every settings page on its skeleton state so no other hooks fire.
+vi.mock("@/hooks/useSettingsForm", () => ({
+ useSettingsForm: () => ({ isLoading: true }),
+}));
+
+function renderLayout(search = "") {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe("AdminSettingsLayout", () => {
+ it("renders the grouped navigation sections", () => {
+ const markup = renderLayout();
+
+ for (const group of ["Server", "Media", "Connections", "Data"]) {
+ expect(markup).toContain(`>${group}<`);
+ }
+ });
+
+ it("renders every settings tab", () => {
+ const markup = renderLayout();
+
+ for (const label of [
+ "General",
+ "Theming",
+ "Card Overlays",
+ "Scanner & Matcher",
+ "Intro Markers",
+ "Subtitles",
+ "Playback",
+ "Downloads",
+ "Watch Providers",
+ "Integrations",
+ "Compatibility Proxies",
+ "Rate Limiting",
+ "Database",
+ "Storage",
+ "Log Retention",
+ ]) {
+ expect(markup).toContain(label);
+ }
+ });
+
+ it("defaults to the General tab", () => {
+ const markup = renderLayout();
+
+ expect(markup).toContain('aria-current="page"');
+ expect(markup).toBe(renderLayout("?tab=general"));
+ });
+
+ it("resolves the legacy jellyfin tab alias to Compatibility Proxies", () => {
+ const withAlias = renderLayout("?tab=jellyfin");
+ const direct = renderLayout("?tab=compatibility-proxies");
+
+ expect(withAlias).toBe(direct);
+ });
+});
diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx
index 0b0d3b9c..d729312f 100644
--- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx
+++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx
@@ -2,6 +2,7 @@ import { useSearchParams } from "react-router";
import {
Settings2,
Captions,
+ Cloud,
PlayCircle,
ScanSearch,
Gauge,
@@ -13,14 +14,20 @@ import {
ScrollText,
Paintbrush,
Layers,
+ Subtitles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
+import { SideNavItem, SideNavSection } from "@/components/SideNav";
+import { cn } from "@/lib/utils";
+
import GeneralSettings from "./GeneralSettings";
import PlaybackSettings from "./PlaybackSettings";
import ScannerSettings from "./ScannerSettings";
import IntroSettings from "./IntroSettings";
+import SubtitlesSettings from "./SubtitlesSettings";
import RateLimitSettings from "./RateLimitSettings";
+import WatchProvidersSettings from "./WatchProvidersSettings";
import IntegrationsSettings from "./IntegrationsSettings";
import CompatibilityProxiesSettings from "./CompatibilityProxiesSettings";
import DatabaseSettings from "./DatabaseSettings";
@@ -37,32 +44,68 @@ interface SettingsNav {
component: React.ComponentType;
}
-const SETTINGS_NAV: SettingsNav[] = [
- { id: "general", label: "General", icon: Settings2, component: GeneralSettings },
- { id: "theming", label: "Theming", icon: Paintbrush, component: ThemeSettings },
- { id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings },
- { id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings },
- { id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings },
- { id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings },
- { id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings },
- { id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings },
+interface SettingsNavGroup {
+ label: string;
+ items: SettingsNav[];
+}
+
+// Tab ids are stable URL state (?tab=...) — regroup or reorder freely, but
+// renaming an id breaks bookmarks and deep links.
+const SETTINGS_GROUPS: SettingsNavGroup[] = [
{
- id: "compatibility-proxies",
- label: "Compatibility Proxies",
- icon: Network,
- component: CompatibilityProxiesSettings,
+ label: "Server",
+ items: [
+ { id: "general", label: "General", icon: Settings2, component: GeneralSettings },
+ { id: "theming", label: "Theming", icon: Paintbrush, component: ThemeSettings },
+ { id: "overlays", label: "Card Overlays", icon: Layers, component: OverlaySettings },
+ ],
},
- { id: "database", label: "Database", icon: Database, component: DatabaseSettings },
- { id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings },
{
- id: "log-retention",
- label: "Log Retention",
- icon: ScrollText,
- component: LogRetentionSettings,
+ label: "Media",
+ items: [
+ { id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings },
+ { id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings },
+ { id: "subtitles", label: "Subtitles", icon: Subtitles, component: SubtitlesSettings },
+ { id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings },
+ { id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings },
+ ],
+ },
+ {
+ label: "Connections",
+ items: [
+ {
+ id: "watch-providers",
+ label: "Watch Providers",
+ icon: Cloud,
+ component: WatchProvidersSettings,
+ },
+ { id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings },
+ {
+ id: "compatibility-proxies",
+ label: "Compatibility Proxies",
+ icon: Network,
+ component: CompatibilityProxiesSettings,
+ },
+ { id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings },
+ ],
+ },
+ {
+ label: "Data",
+ items: [
+ { id: "database", label: "Database", icon: Database, component: DatabaseSettings },
+ { id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings },
+ {
+ id: "log-retention",
+ label: "Log Retention",
+ icon: ScrollText,
+ component: LogRetentionSettings,
+ },
+ ],
},
- { id: "overlays", label: "Card Overlays", icon: Layers, component: OverlaySettings },
];
+const SETTINGS_NAV: SettingsNav[] = SETTINGS_GROUPS.flatMap((group) => group.items);
+
export default function AdminSettingsLayout() {
const [searchParams, setSearchParams] = useSearchParams();
const rawActiveId = searchParams.get("tab") || "general";
@@ -84,50 +127,58 @@ export default function AdminSettingsLayout() {