feat(web): add settings search command palette
This commit is contained in:
@@ -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, "-")}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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 */}
|
||||
|
||||
Reference in New Issue
Block a user