diff --git a/web/src/components/AdminSectionCommandDialog.test.tsx b/web/src/components/AdminSectionCommandDialog.test.tsx new file mode 100644 index 00000000..aa100808 --- /dev/null +++ b/web/src/components/AdminSectionCommandDialog.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginInstallation } from "@/api/types"; +import { buildAdminCommandNavSections } from "@/lib/adminNavigation"; + +const mocks = vi.hoisted(() => ({ + navigateToPluginRoute: vi.fn(), +})); + +vi.mock("@/lib/buildPluginHref", () => ({ + navigateToPluginRoute: (...args: unknown[]) => mocks.navigateToPluginRoute(...args), +})); + +import { AdminSectionCommandDialog } from "./AdminSectionCommandDialog"; + +function renderDialog(sections = buildAdminCommandNavSections(undefined)) { + render( + + + + , + ); +} + +function CurrentPath() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +async function openDialog() { + fireEvent.keyDown(window, { key: "k", metaKey: true }); + const searchBox = await screen.findByRole("searchbox", { name: "Search admin sections" }); + await waitFor(() => expect(searchBox).toHaveFocus()); + return searchBox; +} + +describe("AdminSectionCommandDialog", () => { + beforeEach(() => { + mocks.navigateToPluginRoute.mockReset(); + }); + + it("does not render a visible search input before Cmd+K", () => { + renderDialog(); + + expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull(); + }); + + it("opens and focuses admin search with Cmd+K", async () => { + renderDialog(); + + await openDialog(); + + expect(screen.getByRole("option", { name: /Dashboard/ })).toBeInTheDocument(); + }); + + it("searches all admin section groups", async () => { + renderDialog(); + + const searchBox = await openDialog(); + await userEvent.type(searchBox, "history import"); + + expect(screen.getByRole("option", { name: /History Import/ })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /Settings/ })).not.toBeInTheDocument(); + }); + + it("searches individual admin setting labels from the dashboard dialog", async () => { + renderDialog(); + + const searchBox = await openDialog(); + await userEvent.type(searchBox, "pool max open"); + + expect(screen.getByRole("option", { name: /Database/ })).toBeInTheDocument(); + expect(screen.getByText("Pool Max Open")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("option", { name: /Database/ })); + + expect(screen.getByLabelText("Current path")).toHaveTextContent("/admin/settings?tab=database"); + }); + + it("includes admin plugin app destinations", async () => { + const sections = buildAdminCommandNavSections([ + { + id: 7, + plugin_id: "arrproxy", + enabled: true, + routes: [ + { + id: "admin", + method: "GET", + path: "/", + access: "admin", + navigable: true, + navigation_label: "ArrProxy", + navigation_kind: "admin", + static_asset: true, + }, + ], + } as PluginInstallation, + ]); + renderDialog(sections); + + const searchBox = await openDialog(); + await userEvent.type(searchBox, "arrproxy"); + await userEvent.click(screen.getByRole("option", { name: /ArrProxy/ })); + + expect(mocks.navigateToPluginRoute).toHaveBeenCalledWith("/api/v1/plugins/7/"); + expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull(); + }); + + it("closes after choosing an internal result", async () => { + renderDialog(); + + const searchBox = await openDialog(); + await userEvent.type(searchBox, "logs"); + await userEvent.click(screen.getByRole("option", { name: /Logs/ })); + + expect(screen.getByLabelText("Current path")).toHaveTextContent("/admin/logs"); + expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull(); + }); + + it("closes with Escape", async () => { + renderDialog(); + + await openDialog(); + await userEvent.keyboard("{Escape}"); + + await waitFor(() => + expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull(), + ); + }); + + it("captures Cmd+K before document-level global search handlers", async () => { + const globalSearchShortcut = vi.fn(); + document.addEventListener("keydown", globalSearchShortcut); + + try { + renderDialog(); + + fireEvent.keyDown(document.body, { key: "k", metaKey: true }); + + await screen.findByRole("searchbox", { name: "Search admin sections" }); + expect(globalSearchShortcut).not.toHaveBeenCalled(); + } finally { + document.removeEventListener("keydown", globalSearchShortcut); + } + }); +}); diff --git a/web/src/components/AdminSectionCommandDialog.tsx b/web/src/components/AdminSectionCommandDialog.tsx new file mode 100644 index 00000000..a1cd5d8a --- /dev/null +++ b/web/src/components/AdminSectionCommandDialog.tsx @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Search } from "lucide-react"; +import { VisuallyHidden } from "radix-ui"; +import { useNavigate } from "react-router"; + +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { + countSettingsSearchItems, + filterSettingsSearchEntries, + filterSettingsSearchGroups, +} from "@/components/settings/settingsSearch"; +import { navigateToPluginRoute } from "@/lib/buildPluginHref"; +import type { AdminNavGroup, AdminNavItem } from "@/lib/adminNavigation"; +import { cn } from "@/lib/utils"; + +interface AdminSectionCommandDialogProps { + sections: readonly AdminNavGroup[]; +} + +export function AdminSectionCommandDialog({ sections }: AdminSectionCommandDialogProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const navigate = useNavigate(); + + const filteredSections = useMemo( + () => filterSettingsSearchGroups(sections, query), + [query, sections], + ); + const results = useMemo( + () => filteredSections.flatMap((section) => section.items), + [filteredSections], + ); + const resultIndexByHref = useMemo( + () => new Map(results.map((item, index) => [item.href, index])), + [results], + ); + const totalCount = countSettingsSearchItems(sections); + const resultCount = results.length; + const selectedResult = selectedIndex >= 0 ? results[selectedIndex] : undefined; + + const focusSearch = useCallback(() => { + const focus = () => { + inputRef.current?.focus(); + inputRef.current?.select(); + }; + if (typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(focus); + return; + } + window.setTimeout(focus, 0); + }, []); + + const closeDialog = useCallback(() => { + setOpen(false); + setQuery(""); + setSelectedIndex(0); + }, []); + + const openDialog = useCallback(() => { + setOpen(true); + focusSearch(); + }, [focusSearch]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return; + if (event.key.toLowerCase() !== "k") return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + openDialog(); + }; + + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); + }, [openDialog]); + + const pickResult = useCallback( + (item: AdminNavItem) => { + closeDialog(); + if (item.external) { + void navigateToPluginRoute(item.href); + return; + } + navigate(item.href); + }, + [closeDialog, navigate], + ); + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((current) => (resultCount === 0 ? -1 : (current + 1) % resultCount)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((current) => + resultCount === 0 ? -1 : current <= 0 ? resultCount - 1 : current - 1, + ); + } else if (event.key === "Enter") { + if (!selectedResult) return; + event.preventDefault(); + pickResult(selectedResult); + } else if (event.key === "Escape") { + event.preventDefault(); + closeDialog(); + } + } + + return ( + { + if (nextOpen) { + openDialog(); + return; + } + closeDialog(); + }} + > + + + Search admin sections + Search and open admin sections. + +
+
+ +
+ {filteredSections.length > 0 ? ( +
+ {filteredSections.map((section) => ( +
+
+ {section.label} +
+
+ {section.items.map((item) => { + const index = resultIndexByHref.get(item.href) ?? -1; + return ( + setSelectedIndex(index)} + onPick={() => pickResult(item)} + /> + ); + })} +
+
+ ))} +
+ ) : ( +

+ No matching admin sections +

+ )} +
+ +
+ {query.trim() + ? `${resultCount} ${resultCount === 1 ? "match" : "matches"}` + : `${totalCount} admin sections`} +
+
+
+ ); +} + +function AdminCommandResultRow({ + item, + query, + selected, + onMouseEnter, + onPick, +}: { + item: AdminNavItem; + query: string; + selected: boolean; + onMouseEnter: () => void; + onPick: () => void; +}) { + const Icon = item.icon; + const matchingSettings = filterSettingsSearchEntries(item.settings, query).slice(0, 3); + + return ( + + ); +} + +function resultId(href: string) { + return `admin-command-${href.replace(/[^a-z0-9]+/gi, "-")}`; +} diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index ae753aaf..42c27a38 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -1,51 +1,24 @@ 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, - RefreshCw, - SkipForward, -} from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +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"; -import { pluginRouteHref } from "@/lib/pluginRouteHref"; -interface SidebarItem { - label: string; - icon: LucideIcon; - href: string; - exact?: boolean; +interface SidebarItem extends AdminNavItem { badge?: ReactNode; - // external=true means render as (full page navigation) instead of - // react-router . Used for plugin routes mounted at /api/v1/plugins/... - external?: boolean; } -interface SidebarSection { +interface SidebarSection extends Omit { label: string; items: SidebarItem[]; } @@ -75,63 +48,14 @@ 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, href: "/admin", exact: true }, - { - label: "Activity", - icon: Radio, - href: "/admin/activity", - badge: - sessionCount > 0 ? {sessionCount} live : undefined, - }, - { label: "Logs", icon: ScrollText, href: "/admin/logs" }, - ], - }, - { - label: "Content", - items: [ - { 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, 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: "System", - items: [ - { 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" }, - ], - }, - ]; + const activityBadge = + sessionCount > 0 ? {sessionCount} live : 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- @@ -139,19 +63,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { // 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, - href: pluginRouteHref(inst.id, route.path), - external: true, - }); - } - } + const adminPluginItems = buildAdminPluginNavItems(adminInstallations); if (adminPluginItems.length > 0) { sections.push({ label: "Plugin Apps", items: adminPluginItems }); } diff --git a/web/src/components/settings/SettingsSearchInput.tsx b/web/src/components/settings/SettingsSearchInput.tsx new file mode 100644 index 00000000..5a44ea0e --- /dev/null +++ b/web/src/components/settings/SettingsSearchInput.tsx @@ -0,0 +1,93 @@ +import { useEffect, useId, useRef } from "react"; +import { Search, X } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface SettingsSearchInputProps { + value: string; + onChange: (value: string) => void; + resultCount: number; + totalCount: number; + placeholder?: string; + itemLabel?: string; + emptyLabel?: string; + className?: string; +} + +export function SettingsSearchInput({ + value, + onChange, + resultCount, + totalCount, + placeholder = "Search settings", + itemLabel = "settings sections", + emptyLabel = "No matching settings", + className, +}: SettingsSearchInputProps) { + const inputId = useId(); + const inputRef = useRef(null); + const hasQuery = value.trim().length > 0; + const status = hasQuery + ? resultCount === 0 + ? emptyLabel + : `${resultCount} ${resultCount === 1 ? "match" : "matches"}` + : `${totalCount} ${itemLabel}`; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return; + if (event.key.toLowerCase() !== "k") return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + window.addEventListener("keydown", onKeyDown, { capture: true }); + document.addEventListener("keydown", onKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", onKeyDown, { capture: true }); + document.removeEventListener("keydown", onKeyDown, { capture: true }); + }; + }, []); + + return ( +
+ +
+
+

+ {status} +

+
+ ); +} diff --git a/web/src/components/settings/settingsSearch.test.ts b/web/src/components/settings/settingsSearch.test.ts new file mode 100644 index 00000000..7c676258 --- /dev/null +++ b/web/src/components/settings/settingsSearch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { countSettingsSearchItems, filterSettingsSearchGroups } from "./settingsSearch"; + +const groups = [ + { + label: "Server", + items: [ + { + label: "General", + description: "Authentication and logging", + keywords: ["access token", "refresh token", "log level"], + settings: [{ label: "Quiet Subsystems" }], + }, + { + label: "Database", + description: "Postgres and Redis", + keywords: ["connection url", "pool"], + settings: [{ label: "Pool Max Open" }], + }, + ], + }, + { + label: "Playback", + items: [ + { + label: "Subtitles", + description: "Skipping behavior, language, and style", + keywords: ["forced subtitles", "captions"], + }, + ], + }, +]; + +describe("settingsSearch", () => { + it("returns all groups when the query is empty", () => { + const filtered = filterSettingsSearchGroups(groups, ""); + + expect(countSettingsSearchItems(filtered)).toBe(3); + expect(filtered.map((group) => group.label)).toEqual(["Server", "Playback"]); + }); + + it("matches item labels, descriptions, and keywords", () => { + expect(filterSettingsSearchGroups(groups, "redis")).toEqual([ + { + label: "Server", + items: [groups[0]!.items[1]], + }, + ]); + + expect(filterSettingsSearchGroups(groups, "access token")).toEqual([ + { + label: "Server", + items: [groups[0]!.items[0]], + }, + ]); + + expect(filterSettingsSearchGroups(groups, "forced")).toEqual([ + { + label: "Playback", + items: [groups[1]!.items[0]], + }, + ]); + }); + + it("matches individual setting labels", () => { + expect(filterSettingsSearchGroups(groups, "quiet subsystems")).toEqual([ + { + label: "Server", + items: [groups[0]!.items[0]], + }, + ]); + + expect(filterSettingsSearchGroups(groups, "pool max open")).toEqual([ + { + label: "Server", + items: [groups[0]!.items[1]], + }, + ]); + }); + + it("matches section labels by returning the full section", () => { + const filtered = filterSettingsSearchGroups(groups, "server"); + + expect(filtered).toEqual([groups[0]]); + expect(countSettingsSearchItems(filtered)).toBe(2); + }); + + it("does not match short tokens from the middle of a word", () => { + expect(filterSettingsSearchGroups(groups, "pin")).toEqual([]); + }); +}); diff --git a/web/src/components/settings/settingsSearch.ts b/web/src/components/settings/settingsSearch.ts new file mode 100644 index 00000000..ea505a03 --- /dev/null +++ b/web/src/components/settings/settingsSearch.ts @@ -0,0 +1,99 @@ +export interface SettingsSearchEntry { + label: string; + description?: string; + keywords?: readonly string[]; +} + +export interface SettingsSearchItem { + label: string; + description?: string; + keywords?: readonly string[]; + settings?: readonly SettingsSearchEntry[]; +} + +export interface SettingsSearchGroup { + label: string; + items: readonly T[]; +} + +function normalizeSearchText(value: string) { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function searchTokens(query: string) { + const normalized = normalizeSearchText(query); + return normalized ? normalized.split(/\s+/) : []; +} + +function itemSearchText(group: SettingsSearchGroup, item: T) { + const settingText = item.settings?.flatMap((setting) => [ + setting.label, + setting.description, + ...(setting.keywords ?? []), + ]); + + return normalizeSearchText( + [group.label, item.label, item.description, ...(item.keywords ?? []), ...(settingText ?? [])] + .filter(Boolean) + .join(" "), + ); +} + +function entrySearchText(entry: SettingsSearchEntry) { + return normalizeSearchText([entry.label, entry.description, ...(entry.keywords ?? [])].join(" ")); +} + +function textMatchesTokens(text: string, tokens: string[]) { + const words = text.split(/\s+/).filter(Boolean); + + return tokens.every((token) => + words.some((word) => word.startsWith(token) || (token.length >= 4 && word.includes(token))), + ); +} + +export function filterSettingsSearchEntries( + entries: readonly SettingsSearchEntry[] | undefined, + query: string, +) { + const tokens = searchTokens(query); + + if (!entries?.length || !tokens.length) { + return []; + } + + return entries.filter((entry) => textMatchesTokens(entrySearchText(entry), tokens)); +} + +export function filterSettingsSearchGroups( + groups: readonly SettingsSearchGroup[], + query: string, +): SettingsSearchGroup[] { + const tokens = searchTokens(query); + + if (!tokens.length) { + return groups.map((group) => ({ ...group, items: [...group.items] })); + } + + return groups + .map((group) => { + const groupText = normalizeSearchText(group.label); + const groupMatches = textMatchesTokens(groupText, tokens); + const items = groupMatches + ? [...group.items] + : group.items.filter((item) => textMatchesTokens(itemSearchText(group, item), tokens)); + + return { ...group, items }; + }) + .filter((group) => group.items.length > 0); +} + +export function countSettingsSearchItems( + groups: readonly SettingsSearchGroup[], +) { + return groups.reduce((count, group) => count + group.items.length, 0); +} diff --git a/web/src/lib/adminNavigation.ts b/web/src/lib/adminNavigation.ts new file mode 100644 index 00000000..50723bf1 --- /dev/null +++ b/web/src/lib/adminNavigation.ts @@ -0,0 +1,286 @@ +import { + Blocks, + Bot, + CalendarClock, + Captions, + Download, + History, + KeyRound, + LayoutDashboard, + LayoutPanelTop, + Library, + MonitorSmartphone, + PanelsTopLeft, + Puzzle, + Radio, + RefreshCw, + ScrollText, + Send, + Server, + SkipForward, + SlidersHorizontal, + Users, + Wrench, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import type { PluginInstallation } from "@/api/types"; +import type { SettingsSearchGroup, SettingsSearchItem } from "@/components/settings/settingsSearch"; +import { ADMIN_SETTINGS_GROUPS } from "@/lib/adminSettingsSearch"; +import { pluginRouteHref } from "@/lib/pluginRouteHref"; + +export interface AdminNavItem extends SettingsSearchItem { + label: string; + icon: LucideIcon; + href: string; + exact?: boolean; + external?: boolean; +} + +export type AdminNavGroup = SettingsSearchGroup; + +export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ + { + label: "Overview", + items: [ + { + label: "Dashboard", + description: "Live sessions, content health, and server activity.", + keywords: ["overview", "stats", "health", "scan all"], + icon: LayoutDashboard, + href: "/admin", + exact: true, + }, + { + label: "Activity", + description: "Live streams and current playback sessions.", + keywords: ["streams", "sessions", "now playing", "transcode"], + icon: Radio, + href: "/admin/activity", + }, + { + label: "Logs", + description: "Server log stream and operational output.", + keywords: ["server logs", "debug", "tail", "events"], + icon: ScrollText, + href: "/admin/logs", + }, + ], + }, + { + label: "Content", + items: [ + { + label: "Libraries", + description: "Media libraries, paths, scanning, and catalog import/export.", + keywords: ["library", "paths", "scan", "catalog", "seed"], + icon: Library, + href: "/admin/libraries", + }, + { + label: "Collections", + description: "Curated and smart collection management.", + keywords: ["collection groups", "templates", "smart collections"], + icon: LayoutPanelTop, + href: "/admin/collections", + }, + { + label: "Sections", + description: "Home and catalog section configuration.", + keywords: ["home rows", "rails", "featured sections"], + icon: PanelsTopLeft, + href: "/admin/sections", + }, + { + label: "Requests", + description: "User media requests and request handling.", + keywords: ["requested media", "approvals", "overseerr"], + icon: Send, + href: "/admin/requests", + }, + ], + }, + { + label: "Automation", + items: [ + { + label: "Autoscan", + description: "Autoscan sources, queue state, and poller behavior.", + keywords: ["scan queue", "cephfs", "polling", "matcher"], + icon: RefreshCw, + href: "/admin/autoscan", + }, + { + label: "Scheduled Tasks", + description: "Background task schedules, runs, and job history.", + keywords: ["tasks", "jobs", "scheduler", "sync"], + icon: CalendarClock, + href: "/admin/tasks", + }, + { + label: "Subtitles", + description: "Downloaded subtitle records and subtitle admin tools.", + keywords: ["captions", "subtitle downloads", "providers"], + icon: Captions, + href: "/admin/subtitles", + }, + { + label: "Markers", + description: "Intro, recap, and credits marker history.", + keywords: ["intro markers", "credits", "recaps", "chapters"], + icon: SkipForward, + href: "/admin/marker-history", + }, + { + label: "Recommendations", + description: "Recommendation diagnostics, seed data, and ranking controls.", + keywords: ["taste", "ranking", "recommendation seeds"], + icon: Bot, + href: "/admin/recommendations", + }, + ], + }, + { + label: "Users", + items: [ + { + label: "Users", + description: "Accounts, roles, profile settings, and access.", + keywords: ["accounts", "profiles", "roles", "permissions"], + icon: Users, + href: "/admin/users", + }, + { + label: "Devices", + description: "Registered devices, overrides, and per-device settings.", + keywords: ["clients", "device overrides", "sessions"], + icon: MonitorSmartphone, + href: "/admin/devices", + }, + { + label: "Playback History", + description: "Historical playback events across users and profiles.", + keywords: ["history", "watched", "progress", "plays"], + icon: History, + href: "/admin/history", + }, + { + label: "History Import", + description: "Admin history import mappings and bulk import runs.", + keywords: ["emby", "imports", "mappings", "watch history"], + icon: Download, + href: "/admin/history-import", + }, + ], + }, + { + label: "System", + items: [ + { + label: "Settings", + description: "Server-wide settings, integrations, storage, and compatibility proxies.", + keywords: ["configuration", "server settings", "admin settings"], + icon: SlidersHorizontal, + href: "/admin/settings", + }, + { + label: "Plugins", + description: "Plugin catalog, repositories, installs, and plugin configuration.", + keywords: ["extensions", "plugin catalog", "repositories"], + icon: Blocks, + href: "/admin/plugins", + }, + { + label: "Nodes", + description: "Stream nodes and remote worker status.", + keywords: ["stream nodes", "workers", "transcode nodes"], + icon: Server, + href: "/admin/nodes", + }, + { + label: "API Keys", + description: "Admin API keys and tier assignment.", + keywords: ["tokens", "keys", "access", "rate limit tier"], + icon: KeyRound, + href: "/admin/api-keys", + }, + { + label: "Maintenance", + description: "Operational maintenance tools.", + keywords: ["repair", "cleanup", "system maintenance"], + icon: Wrench, + href: "/admin/maintenance", + }, + ], + }, +]; + +export function buildAdminPluginNavItems( + installations: readonly PluginInstallation[] | undefined, +): AdminNavItem[] { + const items: AdminNavItem[] = []; + + for (const installation of installations ?? []) { + if (!installation.enabled) continue; + + for (const route of installation.routes ?? []) { + if (!route.navigable || route.navigation_kind !== "admin") continue; + + const label = route.navigation_label || installation.plugin_id; + items.push({ + label, + description: `${installation.plugin_id} plugin app.`, + keywords: [installation.plugin_id, "plugin", "plugin app"], + icon: Puzzle, + href: pluginRouteHref(installation.id, route.path), + external: true, + }); + } + } + + return items; +} + +export function appendAdminPluginNavSection( + sections: readonly AdminNavGroup[], + installations: readonly PluginInstallation[] | undefined, +): AdminNavGroup[] { + const pluginItems = buildAdminPluginNavItems(installations); + + if (!pluginItems.length) { + return sections.map((section) => ({ ...section, items: [...section.items] })); + } + + return [ + ...sections.map((section) => ({ ...section, items: [...section.items] })), + { label: "Plugin Apps", items: pluginItems }, + ]; +} + +export function appendAdminSettingsNavSection(sections: readonly AdminNavGroup[]): AdminNavGroup[] { + return [ + ...sections.map((section) => ({ ...section, items: [...section.items] })), + { + label: "Admin Settings", + items: ADMIN_SETTINGS_GROUPS.flatMap((group) => + group.items.map((item) => ({ + label: item.label, + description: item.description, + keywords: ["admin settings", group.label, ...(item.keywords ?? [])], + settings: item.settings, + icon: item.icon, + href: `/admin/settings?tab=${encodeURIComponent(item.id)}`, + })), + ), + }, + ]; +} + +export function buildAdminCommandNavSections( + installations: readonly PluginInstallation[] | undefined, +): AdminNavGroup[] { + return appendAdminPluginNavSection( + appendAdminSettingsNavSection(ADMIN_NAV_SECTIONS), + installations, + ); +} diff --git a/web/src/lib/adminSettingsSearch.ts b/web/src/lib/adminSettingsSearch.ts new file mode 100644 index 00000000..0a4b6a8b --- /dev/null +++ b/web/src/lib/adminSettingsSearch.ts @@ -0,0 +1,409 @@ +import { + Bell, + Captions, + Cloud, + Database, + Download, + Gauge, + HardDrive, + Layers, + Mail, + Network, + Paintbrush, + PlayCircle, + Puzzle, + ScanSearch, + ScrollText, + Settings2, + Sparkles, + Subtitles, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import type { SettingsSearchGroup, SettingsSearchItem } from "@/components/settings/settingsSearch"; + +export interface AdminSettingsSearchItem extends SettingsSearchItem { + id: string; + label: string; + description: string; + keywords?: readonly string[]; + settings?: readonly { label: string; description?: string; keywords?: readonly string[] }[]; + icon: LucideIcon; +} + +export type AdminSettingsSearchGroup = SettingsSearchGroup; + +const settingIndex = (...labels: string[]) => labels.map((label) => ({ label })); + +// Tab ids are stable URL state (?tab=...) — regroup or reorder freely, but +// renaming an id breaks bookmarks and deep links. +export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ + { + label: "Server", + items: [ + { + id: "general", + label: "General", + description: "Authentication, token lifetimes, and server logging.", + keywords: ["access token", "refresh token", "expiry", "log level", "quiet subsystems"], + settings: settingIndex( + "Access Token Expiry", + "Refresh Token Expiry", + "Log Level", + "Quiet Subsystems", + ), + icon: Settings2, + }, + { + id: "theming", + label: "Theming", + description: "Server theme defaults and available theme catalog.", + keywords: ["theme", "default theme", "custom css", "community themes", "appearance"], + settings: settingIndex( + "Preview", + "Token Overrides", + "Custom CSS", + "Branding", + "Server Name", + "Login Page Subtitle", + "Theme Catalog URL", + ), + icon: Paintbrush, + }, + { + id: "overlays", + label: "Card Overlays", + description: "Server-wide poster badge and overlay defaults.", + keywords: ["poster", "badges", "defaults.card_overlays", "overlay preset"], + settings: settingIndex( + "Card Overlays Enabled", + "Default Configuration", + "Default style preset", + "Overlay position", + "Overlay enabled", + ), + icon: Layers, + }, + ], + }, + { + label: "Media", + items: [ + { + id: "scanner", + label: "Scanner & Matcher", + description: "Scan workers, matcher workers, batch size, and image caching.", + keywords: ["scanner workers", "matcher workers", "batch size", "metadata cache images"], + settings: settingIndex( + "Scanner Workers", + "Matcher Workers", + "Matcher Batch Size", + "Cache Images to S3", + ), + icon: ScanSearch, + }, + { + id: "intro", + label: "Intro Markers", + description: "Marker lookup mode, playback fetches, providers, and submissions.", + keywords: [ + "intro", + "credits", + "recap", + "markers", + "chapter markers", + "provider contributions", + ], + settings: settingIndex( + "Mode", + "Fetch Markers at Playback if Missing", + "Use for Online Marker Lookup", + "Allow Contributions", + "Auto-submit Local Markers", + "Marker Providers", + ), + icon: Captions, + }, + { + id: "subtitles", + label: "Subtitles", + description: "Downloaded subtitles, provider settings, and subtitle appearance.", + keywords: ["opensubtitles", "providers", "subtitle language", "caption", "downloaded"], + settings: settingIndex( + "Provider settings", + "Downloaded subtitles", + "Subtitle appearance", + "Subtitle language", + "Subtitle behavior", + "Forced subtitles", + ), + icon: Subtitles, + }, + { + id: "ai", + label: "AI Services", + description: "AI provider endpoints, translation, transcription, and quotas.", + keywords: [ + "openai", + "ollama", + "base url", + "api key", + "chat model", + "translation", + "transcription", + "subtitles", + "quota", + ], + settings: settingIndex( + "Base URL", + "Chat model", + "API Key", + "Transcription model", + "Transcription base URL", + "Transcription API key", + "Max concurrent jobs", + "Subtitle translation", + "Subtitle generation from audio", + "Description translation", + "On-view translation", + "Subtitle batch size", + "Subtitle context lines", + "Transcription chunk length (seconds)", + "Transcription limit per account", + "Transcription limit period", + ), + icon: Sparkles, + }, + { + id: "playback", + label: "Playback", + description: "FFmpeg, transcoding, hardware acceleration, segments, and resume behavior.", + keywords: [ + "ffmpeg", + "transcode", + "hardware acceleration", + "chapter thumbnails", + "watched threshold", + "resume threshold", + "4k", + ], + settings: settingIndex( + "FFmpeg Path", + "Transcode Directory", + "Hardware Acceleration", + "Transcoding Enabled", + "Local Transcode Fallback", + "Allow 4K Transcoding", + "Enable Transcode Throttling", + "Throttle Buffer (seconds)", + "Chapter Thumbnail Workers", + "Chapter Thumbnail Execution", + "Chapter Thumbnail Node Capacity", + "HDR Chapter Thumbnail Policy", + "Watched Threshold (%)", + "Min Resume Threshold (%)", + ), + icon: PlayCircle, + }, + { + id: "downloads", + label: "Downloads", + description: "Download enablement, bandwidth, concurrency, and period limits.", + keywords: ["bandwidth", "concurrent downloads", "download limit", "period duration"], + settings: settingIndex( + "Downloads Enabled", + "Server Bandwidth (Mbps)", + "Per-User Bandwidth (Mbps)", + "Max Concurrent Downloads Per User", + "Max Downloads Per Period", + "Period Duration", + ), + icon: Download, + }, + ], + }, + { + label: "Connections", + items: [ + { + id: "watch-providers", + label: "Watch Providers", + description: "Provider integrations for watch history and scrobbling.", + keywords: ["trakt", "import", "export", "scrobble", "watch history", "favorites"], + settings: settingIndex("Client ID", "Client Secret"), + icon: Cloud, + }, + { + id: "integrations", + label: "Integrations", + description: "Third-party integration keys and service connections.", + keywords: ["mdblist", "api key", "metadata lists"], + settings: settingIndex("API Key"), + icon: Puzzle, + }, + { + id: "email", + label: "Email", + description: "SMTP delivery, sender address, digest schedule, and external URL.", + keywords: ["smtp", "mail", "from address", "digest", "external url", "tls"], + settings: settingIndex( + "Email Enabled", + "From Address", + "From Name", + "Host", + "Port", + "Security", + "Username", + "Password", + "Verify", + ), + icon: Mail, + }, + { + id: "notifications", + label: "Notifications", + description: + "Server notification channels, release events, Discord, web push, and webhooks.", + keywords: [ + "release events", + "new episode", + "discord", + "browser push", + "web push", + "webhooks", + "server channels", + ], + settings: settingIndex( + "Record events", + "Enable release events", + "Fan out", + "Enable fanout", + "Delivery Channels", + "In-App", + "Web Push", + "Email", + "Allow Per-Episode Email", + "Digest Hour", + "External URL", + "Discord", + "Client ID", + "Client Secret", + "Bot Token", + "Allow Per-Episode DMs", + "Embed Posters", + "Personal Webhooks", + "Max Webhooks Per Profile", + "Deliveries Per Minute Per Profile", + "Allow Private Destinations", + "Server Channels", + "Batch Window (seconds)", + "Settle Delay (seconds)", + "Max Series Burst", + "Max Event Age (hours)", + "Read Notifications (days)", + "Unread Notifications (days)", + "Processed Events (days)", + ), + icon: Bell, + }, + { + id: "compatibility-proxies", + label: "Compatibility Proxies", + description: "Jellyfin and Audiobookshelf compatibility proxy settings.", + keywords: ["jellyfin", "audiobookshelf", "abs", "public url", "server id", "session ttl"], + settings: settingIndex( + "Public URL", + "Server Name", + "Server ID", + "Emulated Server Version", + "Session TTL", + "Playback Session TTL", + "Enable Audiobookshelf Proxy", + ), + icon: Network, + }, + { + id: "rate-limiting", + label: "Rate Limiting", + description: "Request limits, API tiers, admin limits, and authentication throttles.", + keywords: ["limits", "tiers", "requests", "throttle", "429", "api keys"], + settings: settingIndex( + "Enable Rate Limiting", + "Backend", + "Global Requests Per Second", + "Per-IP Limits", + "Requests / Second", + "Requests / Minute", + "Burst", + "Standard", + "Elevated", + "Login", + "Signup", + "Setup", + "Authentication endpoints", + ), + icon: Gauge, + }, + ], + }, + { + label: "Data", + items: [ + { + id: "database", + label: "Database", + description: "Postgres, Redis, user database pooling, and Litestream settings.", + keywords: [ + "postgres", + "redis", + "connection url", + "user db", + "pool", + "litestream", + "stale grace", + ], + settings: settingIndex( + "Max Connections", + "Enable Redis", + "Connection URL", + "User DB Backend", + "Pool Max Open", + "Idle Timeout", + "Litestream Sync Interval", + "Stale Grace Seconds", + ), + icon: Database, + }, + { + id: "storage", + label: "Storage", + description: "Public and private S3 storage buckets, endpoints, and credentials.", + keywords: ["s3", "bucket", "endpoint", "region", "access key", "secret key", "uploads"], + settings: settingIndex( + "Endpoint", + "Region", + "Path Style", + "Bucket", + "Access Key", + "Secret Key", + "URL Auth Method", + "Read Endpoint", + "Token Secret", + "Token Param", + "Token TTL (seconds)", + ), + icon: HardDrive, + }, + { + id: "log-retention", + label: "Log Retention", + description: "Operations log cleanup, access log cleanup, and retention policies.", + keywords: ["ops log", "access log", "cleanup", "retention", "history"], + settings: settingIndex("Retention Days", "Max Rows", "Max Size (MB)", "Bucket Overrides"), + icon: ScrollText, + }, + ], + }, +]; + +export const ADMIN_SETTINGS_NAV = ADMIN_SETTINGS_GROUPS.flatMap((group) => group.items); diff --git a/web/src/pages/AdminDashboard.tsx b/web/src/pages/AdminDashboard.tsx index 9e83b157..4de840c8 100644 --- a/web/src/pages/AdminDashboard.tsx +++ b/web/src/pages/AdminDashboard.tsx @@ -2,8 +2,10 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate } from "react-router"; import { AdminSessionActions } from "@/components/AdminSessionActions"; +import { AdminSectionCommandDialog } from "@/components/AdminSectionCommandDialog"; import { useEventChannel } from "@/components/realtimeEventsContext"; import { fetchAdminStats, useAdminStats, useAdminSessions } from "@/hooks/queries/admin/stats"; +import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; import { useAdminUsers } from "@/hooks/queries/admin/users"; import { useAdminLibraries, @@ -49,6 +51,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { adminKeys } from "@/hooks/queries/keys"; import { usePageActivity } from "@/hooks/usePageActivity"; import { cn } from "@/lib/utils"; +import { buildAdminCommandNavSections } from "@/lib/adminNavigation"; import { compareActiveScans, formatActiveScanMode, formatActiveScanProgress } from "@/lib/scanRuns"; const REFRESH_SPINNER_MIN_VISIBLE_MS = 1_000; @@ -77,6 +80,7 @@ export default function AdminDashboard() { const sessionsQuery = useAdminSessions(); const librariesQuery = useAdminLibraries(); const usersQuery = useAdminUsers(); + const { data: adminInstallations } = useAdminPluginInstallations(); const scanAll = useScanAllLibraries(); const pageActivity = usePageActivity(); const manualRefreshStartedAtRef = useRef(null); @@ -107,6 +111,10 @@ export default function AdminDashboard() { const lastUpdatedLabel = lastDashboardUpdatedAt ? formatRelativeUpdatedLabel(relativeUpdatedNow, lastDashboardUpdatedAt) : null; + const adminSearchSections = useMemo( + () => buildAdminCommandNavSections(adminInstallations), + [adminInstallations], + ); useEffect(() => { if (!lastDashboardUpdatedAt) { @@ -211,6 +219,8 @@ export default function AdminDashboard() { return (
+ + {/* Page header */}
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() { }} >
- {flatItems.map((item) => { + {filteredFlatItems.map((item) => { const isActive = item.path === activeSegment; const Icon = item.icon; return ( @@ -209,6 +389,11 @@ export default function SettingsLayout() { ); })} + {filteredFlatItems.length === 0 ? ( +

+ No matching settings +

+ ) : null}
@@ -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 */}