feat(web): add settings search command palette

This commit is contained in:
Quick
2026-06-12 12:47:55 -04:00
parent 4e521ed2e8
commit 9508449859
13 changed files with 1774 additions and 209 deletions
@@ -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(
<MemoryRouter initialEntries={["/admin"]}>
<AdminSectionCommandDialog sections={sections} />
<CurrentPath />
</MemoryRouter>,
);
}
function CurrentPath() {
const location = useLocation();
return <output aria-label="Current path">{`${location.pathname}${location.search}`}</output>;
}
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);
}
});
});
@@ -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<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
openDialog();
return;
}
closeDialog();
}}
>
<DialogContent
className="top-[18%] max-h-[min(34rem,calc(100dvh-4rem))] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-xl"
showCloseButton={false}
>
<VisuallyHidden.Root>
<DialogTitle>Search admin sections</DialogTitle>
<DialogDescription>Search and open admin sections.</DialogDescription>
</VisuallyHidden.Root>
<div className="border-border flex h-12 items-center border-b px-4">
<Search className="text-muted-foreground mr-3 h-4 w-4 shrink-0" aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleInputKeyDown}
placeholder="Search admin sections..."
aria-label="Search admin sections"
aria-activedescendant={selectedResult ? resultId(selectedResult.href) : undefined}
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent text-sm outline-none"
autoComplete="off"
autoFocus
/>
<kbd className="bg-muted text-muted-foreground pointer-events-none ml-3 hidden rounded border px-1.5 py-0.5 text-[10px] font-medium select-none sm:inline-flex">
ESC
</kbd>
</div>
<div className="max-h-[min(25rem,58vh)] overflow-y-auto overscroll-contain p-2">
{filteredSections.length > 0 ? (
<div role="listbox" aria-label="Admin sections" className="space-y-3">
{filteredSections.map((section) => (
<div key={section.label}>
<div className="text-muted-foreground px-2 pb-1 text-xs font-medium">
{section.label}
</div>
<div className="space-y-1">
{section.items.map((item) => {
const index = resultIndexByHref.get(item.href) ?? -1;
return (
<AdminCommandResultRow
key={item.href}
item={item}
query={query}
selected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onPick={() => pickResult(item)}
/>
);
})}
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">
No matching admin sections
</p>
)}
</div>
<div className="text-muted-foreground border-border border-t px-4 py-2 text-xs">
{query.trim()
? `${resultCount} ${resultCount === 1 ? "match" : "matches"}`
: `${totalCount} admin sections`}
</div>
</DialogContent>
</Dialog>
);
}
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 (
<button
id={resultId(item.href)}
type="button"
role="option"
aria-selected={selected}
data-selected={selected || undefined}
onMouseEnter={onMouseEnter}
onClick={onPick}
className={cn(
"focus-visible:ring-ring/50 flex w-full items-start gap-3 rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/70",
)}
>
<span className="text-muted-foreground mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="text-foreground block text-sm font-medium">{item.label}</span>
{item.description ? (
<span className="text-muted-foreground mt-0.5 block text-xs leading-relaxed">
{item.description}
</span>
) : null}
{matchingSettings.length > 0 ? (
<span className="text-muted-foreground mt-1 block text-xs">
{matchingSettings.map((setting) => setting.label).join(", ")}
</span>
) : null}
</span>
</button>
);
}
function resultId(href: string) {
return `admin-command-${href.replace(/[^a-z0-9]+/gi, "-")}`;
}
+18 -106
View File
@@ -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 <a> (full page navigation) instead of
// react-router <Link>. Used for plugin routes mounted at /api/v1/plugins/...
external?: boolean;
}
interface SidebarSection {
interface SidebarSection extends Omit<AdminNavGroup, "items"> {
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 ? <span className="live-badge">{sessionCount} live</span> : 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 ? <span className="live-badge">{sessionCount} live</span> : 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 });
}
@@ -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<HTMLInputElement>(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 (
<div className={cn("w-full", className)}>
<label htmlFor={inputId} className="sr-only">
{placeholder}
</label>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref={inputRef}
id={inputId}
type="search"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-10 rounded-xl pr-10 pl-9"
autoComplete="off"
/>
{hasQuery ? (
<button
type="button"
aria-label="Clear settings search"
onClick={() => onChange("")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
</div>
<p className="text-muted-foreground mt-2 text-xs" aria-live="polite">
{status}
</p>
</div>
);
}
@@ -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([]);
});
});
@@ -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<T extends SettingsSearchItem> {
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<T extends SettingsSearchItem>(group: SettingsSearchGroup<T>, 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<T extends SettingsSearchItem>(
groups: readonly SettingsSearchGroup<T>[],
query: string,
): SettingsSearchGroup<T>[] {
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<T extends SettingsSearchItem>(
groups: readonly SettingsSearchGroup<T>[],
) {
return groups.reduce((count, group) => count + group.items.length, 0);
}
+286
View File
@@ -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<AdminNavItem>;
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,
);
}
+409
View File
@@ -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<AdminSettingsSearchItem>;
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);
+10
View File
@@ -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<number | null>(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 (
<div className="space-y-6 lg:space-y-8">
<AdminSectionCommandDialog sections={adminSearchSections} />
{/* Page header */}
<div className="page-header">
<div className="space-y-3">
+42
View File
@@ -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(
<MemoryRouter initialEntries={["/settings/playback"]}>
<SettingsLayout />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/settings/playback"]}>
<SettingsLayout />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/settings/playback"]}>
<SettingsLayout />
</MemoryRouter>,
);
const searchBox = screen.getByRole("searchbox", { name: "Search settings" });
fireEvent.keyDown(document, { key: "k", metaKey: true });
expect(searchBox).toHaveFocus();
});
});
+195 -7
View File
@@ -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.
</p>
</div>
<SettingsSearchInput
value={settingsSearch}
onChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={flatItems.length}
className="w-full sm:max-w-sm"
/>
</div>
{/* Mobile: horizontal scrolling tab bar */}
@@ -189,7 +369,7 @@ export default function SettingsLayout() {
}}
>
<div className="flex min-w-max items-stretch gap-1">
{flatItems.map((item) => {
{filteredFlatItems.map((item) => {
const isActive = item.path === activeSegment;
const Icon = item.icon;
return (
@@ -209,6 +389,11 @@ export default function SettingsLayout() {
</Link>
);
})}
{filteredFlatItems.length === 0 ? (
<p className="text-muted-foreground px-3 py-2.5 text-sm whitespace-nowrap">
No matching settings
</p>
) : null}
</div>
</nav>
@@ -216,7 +401,7 @@ export default function SettingsLayout() {
<div className="mt-8 min-w-0 flex-1 lg:mt-10 lg:flex lg:gap-10">
<aside className="hidden lg:block lg:w-[220px] lg:shrink-0">
<nav aria-label="Settings sections" className="sticky top-6 space-y-5 pl-3">
{visibleSections.map((section) => (
{filteredSections.map((section) => (
<SideNavSection key={section.label} label={section.label} idPrefix="settings-nav">
{section.items.map((item) => (
<SideNavItem
@@ -229,6 +414,9 @@ export default function SettingsLayout() {
))}
</SideNavSection>
))}
{filteredSections.length === 0 ? (
<p className="text-muted-foreground px-2 text-sm">No matching settings</p>
) : null}
</nav>
</aside>
@@ -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 &amp; 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(
<MemoryRouter initialEntries={["/admin/settings"]}>
<AdminSettingsLayout />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/admin/settings"]}>
<AdminSettingsLayout />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/admin/settings"]}>
<AdminSettingsLayout />
</MemoryRouter>,
);
const searchBox = screen.getByRole("searchbox", { name: "Search settings" });
fireEvent.keyDown(document, { key: "k", metaKey: true });
expect(searchBox).toHaveFocus();
});
});
@@ -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<string, ComponentType> = {
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 (
<div className="space-y-6">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
<p className="page-subtitle text-sm sm:text-base">
Configure server-wide settings. Most changes require a server restart to take effect.
</p>
<div className="page-header gap-5">
<div className="min-w-0 space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
<p className="page-subtitle text-sm sm:text-base">
Configure server-wide settings. Most changes require a server restart to take effect.
</p>
</div>
<SettingsSearchInput
value={settingsSearch}
onChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={SETTINGS_NAV.length}
className="w-full sm:max-w-sm"
/>
</div>
<div className="surface-panel flex min-h-[500px] flex-col overflow-hidden rounded-[1.8rem] border-0 lg:flex-row">
@@ -148,7 +128,7 @@ export default function AdminSettingsLayout() {
style={{ WebkitOverflowScrolling: "touch" }}
>
<div className="flex min-w-max items-stretch gap-1">
{SETTINGS_NAV.map((item) => {
{filteredSettingsNav.map((item) => {
const isActive = item.id === active.id;
return (
<button
@@ -168,6 +148,11 @@ export default function AdminSettingsLayout() {
</button>
);
})}
{filteredSettingsNav.length === 0 ? (
<p className="text-muted-foreground px-3 py-2.5 text-sm whitespace-nowrap">
No matching settings
</p>
) : null}
</div>
</nav>
@@ -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) => (
<SideNavSection key={group.label} label={group.label} idPrefix="admin-settings-nav">
{group.items.map((item) => (
<SideNavItem
@@ -189,6 +174,9 @@ export default function AdminSettingsLayout() {
))}
</SideNavSection>
))}
{filteredSettingsGroups.length === 0 ? (
<p className="text-muted-foreground px-2 text-sm">No matching settings</p>
) : null}
</nav>
{/* Content area */}