diff --git a/web/src/pages/SettingsLayout.test.tsx b/web/src/pages/SettingsLayout.test.tsx
index 60621a52..5e5ca755 100644
--- a/web/src/pages/SettingsLayout.test.tsx
+++ b/web/src/pages/SettingsLayout.test.tsx
@@ -1,3 +1,5 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -99,4 +101,44 @@ describe("SettingsLayout", () => {
expect(markup).toContain("/settings/profiles");
expect(markup).toContain(">Profiles<");
});
+
+ it("filters personal settings sections from the search box", async () => {
+ render(
+
+
+ ,
+ );
+
+ await userEvent.type(screen.getByRole("searchbox", { name: "Search settings" }), "pin");
+
+ expect(screen.getAllByRole("link", { name: /Profiles/ })).toHaveLength(2);
+ expect(screen.queryByRole("link", { name: /Playback/ })).not.toBeInTheDocument();
+ expect(screen.getByText("1 match")).toBeInTheDocument();
+ });
+
+ it("matches individual personal setting labels", async () => {
+ render(
+
+
+ ,
+ );
+
+ await userEvent.type(screen.getByRole("searchbox", { name: "Search settings" }), "font family");
+
+ expect(screen.getAllByRole("link", { name: /Subtitles/ })).toHaveLength(2);
+ expect(screen.queryByRole("link", { name: /Playback/ })).not.toBeInTheDocument();
+ });
+
+ it("focuses personal settings search with Cmd+K", () => {
+ render(
+
+
+ ,
+ );
+
+ const searchBox = screen.getByRole("searchbox", { name: "Search settings" });
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
+
+ expect(searchBox).toHaveFocus();
+ });
});
diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx
index f7222ffd..ee58c208 100644
--- a/web/src/pages/SettingsLayout.tsx
+++ b/web/src/pages/SettingsLayout.tsx
@@ -1,3 +1,4 @@
+import { useMemo, useState } from "react";
import { Link, Outlet, useLocation } from "react-router";
import {
Play,
@@ -19,10 +20,15 @@ import {
import type { LucideIcon } from "lucide-react";
import PageBack from "@/components/PageBack";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
+import { SettingsSearchInput } from "@/components/settings/SettingsSearchInput";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import { useIsActingAdmin } from "@/hooks/useIsActingAdmin";
import { resolveSettingsDocumentTitle } from "@/lib/documentTitle";
+import {
+ countSettingsSearchItems,
+ filterSettingsSearchGroups,
+} from "@/components/settings/settingsSearch";
import { cn } from "@/lib/utils";
interface NavItem {
@@ -30,6 +36,8 @@ interface NavItem {
label: string;
icon: LucideIcon;
description: string;
+ keywords?: readonly string[];
+ settings?: readonly { label: string; description?: string; keywords?: readonly string[] }[];
primaryOrAdmin?: boolean;
}
@@ -38,6 +46,8 @@ interface NavSection {
items: NavItem[];
}
+const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }));
+
const NAV_SECTIONS: NavSection[] = [
{
label: "Playback",
@@ -47,12 +57,56 @@ const NAV_SECTIONS: NavSection[] = [
label: "Playback",
icon: Play,
description: "Quality, language, and skipping",
+ keywords: [
+ "video quality",
+ "spoken language",
+ "metadata language",
+ "auto skip",
+ "auto play",
+ "next up",
+ "preview",
+ ],
+ settings: settingIndex(
+ "Video quality",
+ "Spoken language",
+ "Metadata language",
+ "Auto-skip intros",
+ "Auto-skip credits",
+ "Auto-skip recaps",
+ "Start next at preview",
+ "Auto-play next episode",
+ "Next up episodes",
+ ),
},
{
path: "subtitle-appearance",
label: "Subtitles",
icon: Subtitles,
description: "Language, behavior, and style",
+ keywords: [
+ "subtitle language",
+ "forced subtitles",
+ "captions",
+ "font size",
+ "font color",
+ "background",
+ "position",
+ ],
+ settings: settingIndex(
+ "Subtitle language",
+ "Subtitle behavior",
+ "Show forced subtitles",
+ "Preview",
+ "Font size",
+ "Font family",
+ "Font color",
+ "Text outline",
+ "Outline color",
+ "Background style",
+ "Background opacity",
+ "Background color",
+ "Subtitle position",
+ ),
},
],
},
@@ -64,36 +118,63 @@ const NAV_SECTIONS: NavSection[] = [
label: "Appearance",
icon: Palette,
description: "Theme and interface tone",
+ keywords: ["theme", "profile theme", "dark", "light", "custom theme"],
+ settings: settingIndex("Theme", "Current selection", "Reset to Cinema Dark"),
},
{
path: "theme-editor",
label: "Theme Editor",
icon: Wand2,
description: "Customize colors and CSS",
+ keywords: ["design tokens", "token overrides", "custom css", "community themes"],
+ settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
},
{
path: "accessibility",
label: "Accessibility",
icon: Eye,
description: "Readability and contrast",
+ keywords: ["contrast", "readability", "motion", "transparency", "text"],
+ settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
},
{
path: "home-screen",
label: "Home Screen",
icon: LayoutDashboard,
description: "Sections and layout",
+ keywords: ["sections", "rows", "continue watching", "next up", "library order"],
+ settings: settingIndex(
+ "Scope",
+ "Sections",
+ "Reset section customizations",
+ "Continue Watching",
+ "Next Up",
+ "Recently Added",
+ "Library order",
+ ),
},
{
path: "card-overlays",
label: "Card Overlays",
icon: Layers,
description: "Badges on poster cards",
+ keywords: ["poster", "badges", "overlay", "accent color", "preset"],
+ settings: settingIndex(
+ "Preview",
+ "Preset",
+ "Accent color",
+ "Show icon",
+ "Position",
+ "How styling works",
+ ),
},
{
path: "personalize",
label: "Personalize",
icon: Sparkles,
description: "Re-tune your taste profile",
+ keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
+ settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
},
],
},
@@ -105,24 +186,79 @@ const NAV_SECTIONS: NavSection[] = [
label: "Libraries",
icon: Library,
description: "Visibility and access",
+ keywords: [
+ "library visibility",
+ "access",
+ "disabled libraries",
+ "library order",
+ "playback preferences",
+ ],
+ settings: settingIndex(
+ "Remember library pages",
+ "Library visibility",
+ "Library order",
+ "Spoken language",
+ "Subtitle language",
+ "Subtitle behavior",
+ "Forced subtitles",
+ "Playback preferences",
+ ),
},
{
path: "history-import",
label: "History Import",
icon: Clock,
description: "Emby watch history",
+ keywords: ["emby", "watched history", "import", "mapping", "sync"],
+ settings: settingIndex(
+ "New import",
+ "Import history",
+ "Fetched",
+ "Matched",
+ "Unmatched",
+ "Progress",
+ "History",
+ "Skipped",
+ ),
},
{
path: "webhook-sync",
label: "Webhook Sync",
icon: Server,
description: "Plex, Emby, and Jellyfin webhook intake",
+ keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
+ settings: settingIndex(
+ "Add a connection",
+ "Connected servers",
+ "Recent deliveries",
+ "Plex",
+ "Emby",
+ "Jellyfin",
+ "Server URL",
+ "Token",
+ ),
},
{
path: "watch-providers",
label: "Watch Providers",
icon: Cloud,
description: "Trakt watch history and scrobbling",
+ keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
+ settings: settingIndex(
+ "Last imported",
+ "Last exported",
+ "Watched",
+ "Progress",
+ "Favorites",
+ "Exported",
+ "Import watched history",
+ "Import paused progress",
+ "Send watched changes",
+ "Send unwatched changes",
+ "Sync favorites",
+ "Sync favorite removals",
+ "Scrobble playback",
+ ),
},
],
},
@@ -134,12 +270,32 @@ const NAV_SECTIONS: NavSection[] = [
label: "Notifications",
icon: Bell,
description: "New-episode alerts and webhooks",
+ keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
+ settings: settingIndex(
+ "New Episode Notifications",
+ "Email Notifications",
+ "Discord Notifications",
+ "Browser Notifications",
+ "Webhooks",
+ "Per-episode alerts",
+ "Digest",
+ "Webhook URL",
+ ),
},
{
path: "profiles",
label: "Profiles",
icon: Users,
description: "Names, PINs, and access rules",
+ keywords: ["profile name", "pin", "access", "primary profile", "household"],
+ settings: settingIndex(
+ "Profile name",
+ "PIN",
+ "Library access",
+ "Create profile",
+ "Delete profile",
+ "Primary profile",
+ ),
primaryOrAdmin: true,
},
],
@@ -148,18 +304,35 @@ const NAV_SECTIONS: NavSection[] = [
export default function SettingsLayout() {
const location = useLocation();
+ const [settingsSearch, setSettingsSearch] = useState("");
const { profile } = useCurrentProfile();
const actingAdmin = useIsActingAdmin();
const segments = location.pathname.split("/");
const activeSegment = segments[2] || "playback";
const canManageProfiles = actingAdmin || profile?.is_primary === true;
- const visibleSections = NAV_SECTIONS.map((section) => ({
- ...section,
- items: section.items.filter((item) => !item.primaryOrAdmin || canManageProfiles),
- })).filter((section) => section.items.length > 0);
+ const visibleSections = useMemo(
+ () =>
+ NAV_SECTIONS.map((section) => ({
+ ...section,
+ items: section.items.filter((item) => !item.primaryOrAdmin || canManageProfiles),
+ })).filter((section) => section.items.length > 0),
+ [canManageProfiles],
+ );
- const flatItems = visibleSections.flatMap((section) => section.items);
+ const flatItems = useMemo(
+ () => visibleSections.flatMap((section) => section.items),
+ [visibleSections],
+ );
+ const filteredSections = useMemo(
+ () => filterSettingsSearchGroups(visibleSections, settingsSearch),
+ [settingsSearch, visibleSections],
+ );
+ const filteredFlatItems = useMemo(
+ () => filteredSections.flatMap((section) => section.items),
+ [filteredSections],
+ );
+ const filteredSettingsCount = countSettingsSearchItems(filteredSections);
useDocumentTitle(resolveSettingsDocumentTitle(location.pathname));
@@ -174,6 +347,13 @@ export default function SettingsLayout() {
Manage your playback preferences, libraries, and display options.
+
{/* Mobile: horizontal scrolling tab bar */}
@@ -189,7 +369,7 @@ export default function SettingsLayout() {
}}
>
@@ -216,7 +401,7 @@ export default function SettingsLayout() {
diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx
index 461e4a07..d640ea6f 100644
--- a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx
+++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx
@@ -1,3 +1,5 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -37,10 +39,12 @@ describe("AdminSettingsLayout", () => {
"Scanner & Matcher",
"Intro Markers",
"Subtitles",
+ "AI Services",
"Playback",
"Downloads",
"Watch Providers",
"Integrations",
+ "Email",
"Notifications",
"Compatibility Proxies",
"Rate Limiting",
@@ -65,4 +69,47 @@ describe("AdminSettingsLayout", () => {
expect(withAlias).toBe(direct);
});
+
+ it("filters admin settings sections from the search box", async () => {
+ render(
+
+
+ ,
+ );
+
+ await userEvent.type(screen.getByRole("searchbox", { name: "Search settings" }), "redis");
+
+ expect(screen.getAllByRole("button", { name: /Database/ })).toHaveLength(2);
+ expect(screen.queryByRole("button", { name: /Playback/ })).not.toBeInTheDocument();
+ expect(screen.getByText("1 match")).toBeInTheDocument();
+ });
+
+ it("matches individual admin setting labels", async () => {
+ render(
+
+
+ ,
+ );
+
+ await userEvent.type(
+ screen.getByRole("searchbox", { name: "Search settings" }),
+ "pool max open",
+ );
+
+ expect(screen.getAllByRole("button", { name: /Database/ })).toHaveLength(2);
+ expect(screen.queryByRole("button", { name: /General/ })).not.toBeInTheDocument();
+ });
+
+ it("focuses admin settings search with Cmd+K", () => {
+ render(
+
+
+ ,
+ );
+
+ const searchBox = screen.getByRole("searchbox", { name: "Search settings" });
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
+
+ expect(searchBox).toHaveFocus();
+ });
});
diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx
index 2ff14308..13e8c868 100644
--- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx
+++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx
@@ -1,27 +1,17 @@
+import { useMemo, useState, type ComponentType } from "react";
import { useSearchParams } from "react-router";
-import {
- Settings2,
- Captions,
- Cloud,
- PlayCircle,
- ScanSearch,
- Gauge,
- Download,
- Puzzle,
- Network,
- Database,
- HardDrive,
- ScrollText,
- Paintbrush,
- Layers,
- Subtitles,
- Sparkles,
- Mail,
- Bell,
-} from "lucide-react";
-import type { LucideIcon } from "lucide-react";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
+import { SettingsSearchInput } from "@/components/settings/SettingsSearchInput";
+import {
+ countSettingsSearchItems,
+ filterSettingsSearchGroups,
+} from "@/components/settings/settingsSearch";
+import {
+ ADMIN_SETTINGS_GROUPS,
+ ADMIN_SETTINGS_NAV,
+ type AdminSettingsSearchItem,
+} from "@/lib/adminSettingsSearch";
import { cn } from "@/lib/utils";
import EmailSettings from "./EmailSettings";
@@ -43,11 +33,8 @@ import LogRetentionSettings from "./LogRetentionSettings";
import ThemeSettings from "./ThemeSettings";
import OverlaySettings from "./OverlaySettings";
-interface SettingsNav {
- id: string;
- label: string;
- icon: LucideIcon;
- component: React.ComponentType;
+interface SettingsNav extends AdminSettingsSearchItem {
+ component: ComponentType;
}
interface SettingsNavGroup {
@@ -55,75 +42,59 @@ interface SettingsNavGroup {
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[] = [
- {
- 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 },
- ],
- },
- {
- 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: "ai", label: "AI Services", icon: Sparkles, component: AIServicesSettings },
- { 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: "email", label: "Email", icon: Mail, component: EmailSettings },
- {
- id: "notifications",
- label: "Notifications",
- icon: Bell,
- component: NotificationsAdminSettings,
- },
- {
- 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,
- },
- ],
- },
-];
+const SETTINGS_COMPONENTS: Record
= {
+ general: GeneralSettings,
+ theming: ThemeSettings,
+ overlays: OverlaySettings,
+ scanner: ScannerSettings,
+ intro: IntroSettings,
+ subtitles: SubtitlesSettings,
+ ai: AIServicesSettings,
+ playback: PlaybackSettings,
+ downloads: DownloadSettings,
+ "watch-providers": WatchProvidersSettings,
+ integrations: IntegrationsSettings,
+ email: EmailSettings,
+ notifications: NotificationsAdminSettings,
+ "compatibility-proxies": CompatibilityProxiesSettings,
+ "rate-limiting": RateLimitSettings,
+ database: DatabaseSettings,
+ storage: StorageSettings,
+ "log-retention": LogRetentionSettings,
+};
-const SETTINGS_NAV: SettingsNav[] = SETTINGS_GROUPS.flatMap((group) => group.items);
+function settingsComponent(id: string) {
+ const component = SETTINGS_COMPONENTS[id];
+ if (!component) {
+ throw new Error(`Missing admin settings component for ${id}`);
+ }
+ return component;
+}
+
+const SETTINGS_GROUPS: SettingsNavGroup[] = ADMIN_SETTINGS_GROUPS.map((group) => ({
+ ...group,
+ items: group.items.map((item) => ({ ...item, component: settingsComponent(item.id) })),
+}));
+
+const SETTINGS_NAV: SettingsNav[] = ADMIN_SETTINGS_NAV.map((item) => ({
+ ...item,
+ component: settingsComponent(item.id),
+}));
export default function AdminSettingsLayout() {
const [searchParams, setSearchParams] = useSearchParams();
+ const [settingsSearch, setSettingsSearch] = useState("");
const rawActiveId = searchParams.get("tab") || "general";
const activeId = rawActiveId === "jellyfin" ? "compatibility-proxies" : rawActiveId;
+ const filteredSettingsGroups = useMemo(
+ () => filterSettingsSearchGroups(SETTINGS_GROUPS, settingsSearch),
+ [settingsSearch],
+ );
+ const filteredSettingsNav = useMemo(
+ () => filteredSettingsGroups.flatMap((group) => group.items),
+ [filteredSettingsGroups],
+ );
+ const filteredSettingsCount = countSettingsSearchItems(filteredSettingsGroups);
function setActiveId(id: string) {
setSearchParams({ tab: id }, { replace: true });
@@ -133,11 +104,20 @@ export default function AdminSettingsLayout() {
return (
-
-
Settings
-
- Configure server-wide settings. Most changes require a server restart to take effect.
-
+
+
+
Settings
+
+ Configure server-wide settings. Most changes require a server restart to take effect.
+
+
+
@@ -148,7 +128,7 @@ export default function AdminSettingsLayout() {
style={{ WebkitOverflowScrolling: "touch" }}
>
- {SETTINGS_NAV.map((item) => {
+ {filteredSettingsNav.map((item) => {
const isActive = item.id === active.id;
return (
@@ -176,7 +161,7 @@ export default function AdminSettingsLayout() {
aria-label="Admin settings sections"
className="border-border hidden space-y-5 border-r px-3 py-4 lg:block lg:w-60 lg:flex-shrink-0"
>
- {SETTINGS_GROUPS.map((group) => (
+ {filteredSettingsGroups.map((group) => (
{group.items.map((item) => (
))}
+ {filteredSettingsGroups.length === 0 ? (
+ No matching settings
+ ) : null}
{/* Content area */}