feat(web): regroup admin navigation and settings into intuitive sections

Reorganize the admin sidebar into Overview / Content / Automation /
Users / System so media pipeline tools no longer hide under Users and
Settings is not buried in a seven-item Server grab-bag. Group the flat
13-tab admin settings page into Server / Media / Connections / Data
sections, and split the Integrations tab into dedicated Subtitles
(search providers + AI translation) and Watch Providers (Trakt/Simkl
OAuth) pages, leaving Integrations for one-off keys like MDBList. All
routes and ?tab= ids are unchanged so deep links keep working.

Extract the shared grouped-rail markup used by the admin sidebar, admin
settings nav, and user settings nav into a SideNav component, and
remove the orphaned admin-settings PluginsSettings page (superseded by
/admin/plugins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-09 14:23:10 -04:00
co-authored by Claude Fable 5
parent 2c714e4ef2
commit 89f005dfa8
13 changed files with 878 additions and 1449 deletions
+13 -5
View File
@@ -54,32 +54,40 @@ function renderSidebar() {
}
describe("AdminSidebar", () => {
it("includes a Sections link in the manage navigation", () => {
it("renders the grouped navigation sections", () => {
const markup = renderSidebar();
for (const section of ["Overview", "Content", "Automation", "Users", "System"]) {
expect(markup).toContain(`>${section}<`);
}
});
it("includes a Sections link in the content navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/sections"');
expect(markup).toContain(">Sections<");
});
it("includes a Maintenance link in the server navigation", () => {
it("includes a Maintenance link in the system navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/maintenance"');
expect(markup).toContain(">Maintenance<");
});
it("includes a Recommendations link in the server navigation", () => {
it("includes a Recommendations link in the automation navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/recommendations"');
expect(markup).toContain(">Recommendations<");
});
it("includes a Marker History link in the users navigation", () => {
it("includes a Markers link in the automation navigation", () => {
const markup = renderSidebar();
expect(markup).toContain('href="/admin/marker-history"');
expect(markup).toContain(">Marker History<");
expect(markup).toContain(">Markers<");
});
it("renders the build identifier in the footer", () => {
+65 -169
View File
@@ -22,8 +22,11 @@ import {
Puzzle,
Send,
RefreshCw,
SkipForward,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { SiloBrand } from "@/components/SiloBrand";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins";
@@ -33,7 +36,7 @@ import { pluginRouteHref } from "@/lib/pluginRouteHref";
interface SidebarItem {
label: string;
icon: ReactNode;
icon: LucideIcon;
href: string;
exact?: boolean;
badge?: ReactNode;
@@ -72,133 +75,60 @@ 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 className="h-[18px] w-[18px]" />,
href: "/admin",
exact: true,
},
{ label: "Dashboard", icon: LayoutDashboard, href: "/admin", exact: true },
{
label: "Activity",
icon: <Radio className="h-[18px] w-[18px]" />,
icon: Radio,
href: "/admin/activity",
badge:
sessionCount > 0 ? <span className="live-badge">{sessionCount} live</span> : undefined,
},
{
label: "Logs",
icon: <ScrollText className="h-[18px] w-[18px]" />,
href: "/admin/logs",
},
{ label: "Logs", icon: ScrollText, href: "/admin/logs" },
],
},
{
label: "Content",
items: [
{
label: "Libraries",
icon: <Library className="h-[18px] w-[18px]" />,
href: "/admin/libraries",
},
{
label: "Collections",
icon: <LayoutPanelTop className="h-[18px] w-[18px]" />,
href: "/admin/collections",
},
{
label: "Requests",
icon: <Send className="h-[18px] w-[18px]" />,
href: "/admin/requests",
},
{
label: "Autoscan",
icon: <RefreshCw className="h-[18px] w-[18px]" />,
href: "/admin/autoscan",
},
{
label: "Sections",
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
href: "/admin/sections",
},
{
label: "Subtitles",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/subtitles",
},
{ 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 className="h-[18px] w-[18px]" />,
href: "/admin/users",
},
{
label: "Devices",
icon: <MonitorSmartphone className="h-[18px] w-[18px]" />,
href: "/admin/devices",
},
{
label: "Playback History",
icon: <History className="h-[18px] w-[18px]" />,
href: "/admin/history",
},
{
label: "Marker History",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/marker-history",
},
{
label: "History Import",
icon: <Download className="h-[18px] w-[18px]" />,
href: "/admin/history-import",
},
{ 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: "Server",
label: "System",
items: [
{
label: "Scheduled Tasks",
icon: <CalendarClock className="h-[18px] w-[18px]" />,
href: "/admin/tasks",
},
{
label: "Nodes",
icon: <Server className="h-[18px] w-[18px]" />,
href: "/admin/nodes",
},
{
label: "Maintenance",
icon: <Wrench className="h-[18px] w-[18px]" />,
href: "/admin/maintenance",
},
{
label: "Plugins",
icon: <Blocks className="h-[18px] w-[18px]" />,
href: "/admin/plugins",
},
{
label: "Settings",
icon: <SlidersHorizontal className="h-[18px] w-[18px]" />,
href: "/admin/settings",
},
{
label: "Recommendations",
icon: <Bot className="h-[18px] w-[18px]" />,
href: "/admin/recommendations",
},
{
label: "API Keys",
icon: <KeyRound className="h-[18px] w-[18px]" />,
href: "/admin/api-keys",
},
{ 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" },
],
},
];
@@ -216,7 +146,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
if (!route.navigable || route.navigation_kind !== "admin") continue;
adminPluginItems.push({
label: route.navigation_label || inst.plugin_id,
icon: <Puzzle className="h-[18px] w-[18px]" />,
icon: Puzzle,
href: pluginRouteHref(inst.id, route.path),
external: true,
});
@@ -250,70 +180,36 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
className="sidebar-scroll flex-1 space-y-5 overflow-y-auto px-3"
>
{sections.map((section) => (
<div
key={section.label}
role="group"
aria-labelledby={`admin-nav-${section.label.toLowerCase()}`}
>
<h3
id={`admin-nav-${section.label.toLowerCase()}`}
className="text-muted-foreground mb-2 px-2 text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{section.label}
</h3>
<ul className="list-none space-y-0.5">
{section.items.map((item) => {
const active = isActive(item);
const className = `relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-[13px] font-medium transition-colors duration-150 ${
active
? "text-primary bg-accent"
: "text-muted-foreground hover:text-foreground hover:bg-accent/70"
} `;
const inner = (
<>
{active && (
<span
className="absolute top-1/2 left-[-12px] h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<span className="flex w-[18px] flex-shrink-0 items-center justify-center">
{item.icon}
</span>
<span>{item.label}</span>
{item.badge && <span className="ml-auto">{item.badge}</span>}
</>
);
return (
<li key={item.href}>
{item.external ? (
<a
href={item.href}
onClick={(e) => {
e.preventDefault();
void navigateToPluginRoute(item.href);
onNavigate?.();
}}
aria-current={active ? "page" : undefined}
className={className}
>
{inner}
</a>
) : (
<Link
to={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={className}
>
{inner}
</Link>
)}
</li>
);
})}
</ul>
</div>
<SideNavSection key={section.label} label={section.label} idPrefix="admin-nav">
{section.items.map((item) =>
item.external ? (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
external
active={isActive(item)}
badge={item.badge}
onClick={(e) => {
e.preventDefault();
void navigateToPluginRoute(item.href);
onNavigate?.();
}}
/>
) : (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
active={isActive(item)}
badge={item.badge}
onClick={onNavigate}
/>
),
)}
</SideNavSection>
))}
</nav>
+100
View File
@@ -0,0 +1,100 @@
import { Link } from "react-router";
import type { LucideIcon } from "lucide-react";
import type { MouseEvent, ReactNode } from "react";
import { cn } from "@/lib/utils";
// Shared building blocks for the grouped vertical navigation rails used by the
// admin sidebar, the admin settings page, and the user settings page. Keeping
// the markup here means active states, spacing, and section headers stay
// consistent across all three.
interface SideNavSectionProps {
label: string;
/** Prefix for the section heading id, e.g. "admin-nav". */
idPrefix: string;
children: ReactNode;
}
export function SideNavSection({ label, idPrefix, children }: SideNavSectionProps) {
const headingId = `${idPrefix}-${label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div role="group" aria-labelledby={headingId}>
<h3
id={headingId}
className="text-muted-foreground mb-2 px-2 text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{label}
</h3>
<ul className="list-none space-y-0.5">{children}</ul>
</div>
);
}
interface SideNavItemProps {
label: string;
icon: LucideIcon;
active?: boolean;
badge?: ReactNode;
/** Internal route rendered as a react-router <Link>. */
href?: string;
/**
* With href, render a plain <a> (full page navigation) instead of a
* react-router <Link>. Used for plugin routes mounted at /api/v1/plugins/...
*/
external?: boolean;
/** Without href, the item renders as a <button>. */
onClick?: (event: MouseEvent<HTMLElement>) => void;
}
export function SideNavItem({
label,
icon: Icon,
active = false,
badge,
href,
external,
onClick,
}: SideNavItemProps) {
const className = cn(
"relative flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left text-[13px] font-medium transition-colors duration-150",
active
? "text-primary bg-accent"
: "text-muted-foreground hover:text-foreground hover:bg-accent/70",
);
const inner = (
<>
{active && (
<span
className="absolute top-1/2 left-[-12px] h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<span className="flex w-[18px] flex-shrink-0 items-center justify-center">
<Icon className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 truncate">{label}</span>
{badge && <span className="ml-auto">{badge}</span>}
</>
);
const ariaCurrent = active ? "page" : undefined;
return (
<li>
{href ? (
external ? (
<a href={href} onClick={onClick} aria-current={ariaCurrent} className={className}>
{inner}
</a>
) : (
<Link to={href} onClick={onClick} aria-current={ariaCurrent} className={className}>
{inner}
</Link>
)
) : (
<button type="button" onClick={onClick} aria-current={ariaCurrent} className={className}>
{inner}
</button>
)}
</li>
);
}
+16 -43
View File
@@ -3,6 +3,7 @@ import {
Play,
Library,
Clock,
Cloud,
Subtitles,
LayoutDashboard,
Palette,
@@ -16,6 +17,7 @@ import {
// Sparkles is used by the Personalization nav entry below.
import type { LucideIcon } from "lucide-react";
import PageBack from "@/components/PageBack";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useAuth } from "@/hooks/useAuth";
import { resolveSettingsDocumentTitle } from "@/lib/documentTitle";
@@ -117,7 +119,7 @@ const NAV_SECTIONS: NavSection[] = [
{
path: "watch-providers",
label: "Watch Providers",
icon: Clock,
icon: Cloud,
description: "Trakt watch history and scrobbling",
},
],
@@ -206,48 +208,19 @@ 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) => {
const sectionId = `settings-nav-${section.label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div key={section.label} role="group" aria-labelledby={sectionId}>
<h3
id={sectionId}
className="text-muted-foreground mb-2 px-2 text-[10px] font-semibold tracking-[0.18em] uppercase"
>
{section.label}
</h3>
<ul className="list-none space-y-0.5">
{section.items.map((item) => {
const isActive = item.path === activeSegment;
const Icon = item.icon;
return (
<li key={item.path}>
<Link
to={`/settings/${item.path}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-[13px] font-medium transition-colors duration-150",
isActive
? "text-primary bg-accent"
: "text-muted-foreground hover:text-foreground hover:bg-accent/70",
)}
>
{isActive && (
<span
className="absolute top-1/2 left-[-12px] h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Icon className="h-[18px] w-[18px] flex-shrink-0" />
<span>{item.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
})}
{visibleSections.map((section) => (
<SideNavSection key={section.label} label={section.label} idPrefix="settings-nav">
{section.items.map((item) => (
<SideNavItem
key={item.path}
label={item.label}
icon={item.icon}
href={`/settings/${item.path}`}
active={item.path === activeSegment}
/>
))}
</SideNavSection>
))}
</nav>
</aside>
@@ -0,0 +1,67 @@
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import AdminSettingsLayout from "./AdminSettingsLayout";
// The layout only needs the active tab's component to render; a loading form
// keeps every settings page on its skeleton state so no other hooks fire.
vi.mock("@/hooks/useSettingsForm", () => ({
useSettingsForm: () => ({ isLoading: true }),
}));
function renderLayout(search = "") {
return renderToStaticMarkup(
<MemoryRouter initialEntries={[`/admin/settings${search}`]}>
<AdminSettingsLayout />
</MemoryRouter>,
);
}
describe("AdminSettingsLayout", () => {
it("renders the grouped navigation sections", () => {
const markup = renderLayout();
for (const group of ["Server", "Media", "Connections", "Data"]) {
expect(markup).toContain(`>${group}<`);
}
});
it("renders every settings tab", () => {
const markup = renderLayout();
for (const label of [
"General",
"Theming",
"Card Overlays",
"Scanner &amp; Matcher",
"Intro Markers",
"Subtitles",
"Playback",
"Downloads",
"Watch Providers",
"Integrations",
"Compatibility Proxies",
"Rate Limiting",
"Database",
"Storage",
"Log Retention",
]) {
expect(markup).toContain(label);
}
});
it("defaults to the General tab", () => {
const markup = renderLayout();
expect(markup).toContain('aria-current="page"');
expect(markup).toBe(renderLayout("?tab=general"));
});
it("resolves the legacy jellyfin tab alias to Compatibility Proxies", () => {
const withAlias = renderLayout("?tab=jellyfin");
const direct = renderLayout("?tab=compatibility-proxies");
expect(withAlias).toBe(direct);
});
});
@@ -2,6 +2,7 @@ import { useSearchParams } from "react-router";
import {
Settings2,
Captions,
Cloud,
PlayCircle,
ScanSearch,
Gauge,
@@ -13,14 +14,20 @@ import {
ScrollText,
Paintbrush,
Layers,
Subtitles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { cn } from "@/lib/utils";
import GeneralSettings from "./GeneralSettings";
import PlaybackSettings from "./PlaybackSettings";
import ScannerSettings from "./ScannerSettings";
import IntroSettings from "./IntroSettings";
import SubtitlesSettings from "./SubtitlesSettings";
import RateLimitSettings from "./RateLimitSettings";
import WatchProvidersSettings from "./WatchProvidersSettings";
import IntegrationsSettings from "./IntegrationsSettings";
import CompatibilityProxiesSettings from "./CompatibilityProxiesSettings";
import DatabaseSettings from "./DatabaseSettings";
@@ -37,32 +44,68 @@ interface SettingsNav {
component: React.ComponentType;
}
const SETTINGS_NAV: SettingsNav[] = [
{ id: "general", label: "General", icon: Settings2, component: GeneralSettings },
{ id: "theming", label: "Theming", icon: Paintbrush, component: ThemeSettings },
{ id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings },
{ id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings },
{ id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings },
{ id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings },
{ id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings },
{ id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings },
interface SettingsNavGroup {
label: string;
items: SettingsNav[];
}
// Tab ids are stable URL state (?tab=...) — regroup or reorder freely, but
// renaming an id breaks bookmarks and deep links.
const SETTINGS_GROUPS: SettingsNavGroup[] = [
{
id: "compatibility-proxies",
label: "Compatibility Proxies",
icon: Network,
component: CompatibilityProxiesSettings,
label: "Server",
items: [
{ id: "general", label: "General", icon: Settings2, component: GeneralSettings },
{ id: "theming", label: "Theming", icon: Paintbrush, component: ThemeSettings },
{ id: "overlays", label: "Card Overlays", icon: Layers, component: OverlaySettings },
],
},
{ id: "database", label: "Database", icon: Database, component: DatabaseSettings },
{ id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings },
{
id: "log-retention",
label: "Log Retention",
icon: ScrollText,
component: LogRetentionSettings,
label: "Media",
items: [
{ id: "scanner", label: "Scanner & Matcher", icon: ScanSearch, component: ScannerSettings },
{ id: "intro", label: "Intro Markers", icon: Captions, component: IntroSettings },
{ id: "subtitles", label: "Subtitles", icon: Subtitles, component: SubtitlesSettings },
{ id: "playback", label: "Playback", icon: PlayCircle, component: PlaybackSettings },
{ id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings },
],
},
{
label: "Connections",
items: [
{
id: "watch-providers",
label: "Watch Providers",
icon: Cloud,
component: WatchProvidersSettings,
},
{ id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings },
{
id: "compatibility-proxies",
label: "Compatibility Proxies",
icon: Network,
component: CompatibilityProxiesSettings,
},
{ id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings },
],
},
{
label: "Data",
items: [
{ id: "database", label: "Database", icon: Database, component: DatabaseSettings },
{ id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings },
{
id: "log-retention",
label: "Log Retention",
icon: ScrollText,
component: LogRetentionSettings,
},
],
},
{ id: "overlays", label: "Card Overlays", icon: Layers, component: OverlaySettings },
];
const SETTINGS_NAV: SettingsNav[] = SETTINGS_GROUPS.flatMap((group) => group.items);
export default function AdminSettingsLayout() {
const [searchParams, setSearchParams] = useSearchParams();
const rawActiveId = searchParams.get("tab") || "general";
@@ -84,50 +127,58 @@ export default function AdminSettingsLayout() {
</div>
<div className="surface-panel flex min-h-[500px] flex-col overflow-hidden rounded-[1.8rem] border-0 lg:flex-row">
{/* Sub-nav sidebar */}
{/* Mobile: horizontal scrolling pill bar */}
<nav
className="border-border overflow-x-auto border-b px-2 py-3 lg:w-56 lg:flex-shrink-0 lg:border-r lg:border-b-0 lg:px-0"
aria-label="Admin settings sections"
className="border-border overflow-x-auto border-b p-2 lg:hidden"
style={{ WebkitOverflowScrolling: "touch" }}
>
<div role="tablist" className="flex gap-1 lg:block lg:space-y-1">
<div className="flex min-w-max items-stretch gap-1">
{SETTINGS_NAV.map((item) => {
const isActive = item.id === activeId;
const isActive = item.id === active.id;
return (
<button
key={item.id}
type="button"
role="tab"
id={`tab-${item.id}`}
aria-selected={isActive}
aria-controls={`panel-${item.id}`}
onClick={() => setActiveId(item.id)}
className={`relative flex min-w-max items-center gap-2.5 rounded-xl px-4 py-2.5 text-left text-[13px] font-medium transition-colors lg:w-full ${
aria-current={isActive ? "page" : undefined}
className={cn(
"inline-flex items-center gap-2 rounded-[1rem] px-3 py-2.5 text-[13px] font-medium whitespace-nowrap transition-colors",
isActive
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
}`}
>
{isActive && (
<span
className="absolute top-auto bottom-0 left-1/2 h-[3px] w-8 -translate-x-1/2 rounded-t-sm lg:top-1/2 lg:bottom-auto lg:left-0 lg:h-[18px] lg:w-[3px] lg:-translate-x-0 lg:-translate-y-1/2 lg:rounded-t-none lg:rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/70 hover:text-foreground",
)}
<item.icon className="h-4 w-4 flex-shrink-0" />
<span>{item.label}</span>
>
<item.icon className="h-4 w-4" />
{item.label}
</button>
);
})}
</div>
</nav>
{/* Content area */}
<div
role="tabpanel"
id={`panel-${activeId}`}
aria-labelledby={`tab-${activeId}`}
className="flex-1 overflow-y-auto p-4 sm:p-6"
{/* Desktop: grouped vertical rail */}
<nav
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) => (
<SideNavSection key={group.label} label={group.label} idPrefix="admin-settings-nav">
{group.items.map((item) => (
<SideNavItem
key={item.id}
label={item.label}
icon={item.icon}
active={item.id === active.id}
onClick={() => setActiveId(item.id)}
/>
))}
</SideNavSection>
))}
</nav>
{/* Content area */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
<ActiveComponent />
</div>
</div>
@@ -0,0 +1,18 @@
import { CircleCheck, CircleAlert } from "lucide-react";
export function CredentialStatus({ configured }: { configured: boolean }) {
if (configured) {
return (
<span className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<CircleCheck className="h-3.5 w-3.5 text-green-500" />
Configured
</span>
);
}
return (
<span className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<CircleAlert className="h-3.5 w-3.5 text-yellow-500" />
Not configured
</span>
);
}
@@ -1,357 +1,10 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import {
useSubtitleProviders,
useUpdateSubtitleProvider,
useTestSubtitleProvider,
} from "@/hooks/queries/admin/subtitles";
import {
useAdminSensitiveStatus,
useAdminServerSettings,
useUpdateServerSetting,
} from "@/hooks/queries/admin/settings";
import type { SubtitleProviderConfig } from "@/api/types";
import { useState } from "react";
import { useAdminSensitiveStatus, useUpdateServerSetting } from "@/hooks/queries/admin/settings";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Eye, EyeOff, CircleCheck, CircleAlert } from "lucide-react";
import { CredentialStatus } from "./CredentialStatus";
import { SettingField } from "./SettingField";
// ============================================================================
// Subtitles
// ============================================================================
const SUBTITLE_PROVIDER_NAMES: Record<string, string> = {
opensubtitles: "OpenSubtitles",
subdl: "SubDL",
subsource: "SubSource",
};
interface SubtitleProviderFormState {
enabled: boolean;
api_key: string;
username: string;
password: string;
showApiKey: boolean;
}
interface SubtitleTestResult {
success: boolean;
error?: string;
}
function defaultSubtitleFormState(config: SubtitleProviderConfig): SubtitleProviderFormState {
return {
enabled: config.enabled,
api_key: "",
username: "",
password: "",
showApiKey: false,
};
}
function SubtitleCredentialStatus({ configured }: { configured: boolean }) {
if (configured) {
return (
<span className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<CircleCheck className="h-3.5 w-3.5 text-green-500" />
Configured
</span>
);
}
return (
<span className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<CircleAlert className="h-3.5 w-3.5 text-yellow-500" />
Not configured
</span>
);
}
function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
const [form, setForm] = useState<SubtitleProviderFormState>(() =>
defaultSubtitleFormState(config),
);
const [testResult, setTestResult] = useState<SubtitleTestResult | null>(null);
const updateProvider = useUpdateSubtitleProvider();
const testProvider = useTestSubtitleProvider();
useEffect(() => {
setForm((prev) => ({
...prev,
enabled: config.enabled,
}));
}, [config.enabled]);
const providerName = config.provider_name;
const displayName = SUBTITLE_PROVIDER_NAMES[providerName] ?? providerName;
const isOpenSubtitles = providerName === "opensubtitles";
function handleSave() {
updateProvider.mutate({
provider: providerName,
config: {
enabled: form.enabled,
...(isOpenSubtitles
? { username: form.username, password: form.password }
: { api_key: form.api_key }),
},
});
}
function handleTest() {
setTestResult(null);
testProvider.mutate(providerName, {
onSuccess: (result) => {
setTestResult({ success: result.success, error: result.error });
},
onError: (err) => {
setTestResult({
success: false,
error: err instanceof Error ? err.message : "Test failed",
});
},
});
}
return (
<div className="border-border bg-surface space-y-4 rounded-lg border px-5 py-4">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold">{displayName}</span>
<SubtitleCredentialStatus
configured={isOpenSubtitles ? config.has_credentials : config.has_api_key}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`${providerName}-enabled`} className="text-sm font-medium">
{form.enabled ? "Enabled" : "Disabled"}
</Label>
<Switch
id={`${providerName}-enabled`}
checked={form.enabled}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</div>
{/* Credentials: username/password for OpenSubtitles, API key for others */}
{isOpenSubtitles ? (
<>
<div className="space-y-1">
<Label htmlFor={`${providerName}-username`} className="text-sm font-medium">
Username
</Label>
<Input
id={`${providerName}-username`}
type="text"
placeholder={
config.has_credentials ? "Leave blank to keep current" : "OpenSubtitles username"
}
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`${providerName}-password`} className="text-sm font-medium">
Password
</Label>
<Input
id={`${providerName}-password`}
type="password"
placeholder={
config.has_credentials ? "Leave blank to keep current" : "OpenSubtitles password"
}
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
</div>
</>
) : (
<div className="space-y-1">
<Label htmlFor={`${providerName}-api-key`} className="text-sm font-medium">
API Key
</Label>
<div className="flex items-center gap-2">
<Input
id={`${providerName}-api-key`}
type={form.showApiKey ? "text" : "password"}
placeholder={config.has_api_key ? "Leave blank to keep current" : "Enter API key"}
value={form.api_key}
onChange={(e) => setForm((prev) => ({ ...prev, api_key: e.target.value }))}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => setForm((prev) => ({ ...prev, showApiKey: !prev.showApiKey }))}
>
{form.showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-1">
<Button variant="outline" onClick={handleTest} disabled={testProvider.isPending}>
{testProvider.isPending ? "Testing..." : "Test Connection"}
</Button>
<Button onClick={handleSave} disabled={updateProvider.isPending}>
{updateProvider.isPending ? "Saving..." : "Save"}
</Button>
{testResult !== null && (
<span className={`text-sm ${testResult.success ? "text-green-500" : "text-red-500"}`}>
{testResult.success
? "Connection successful"
: (testResult.error ?? "Connection failed")}
</span>
)}
</div>
</div>
);
}
const SUBTITLE_PROVIDER_ORDER = ["opensubtitles", "subdl", "subsource"];
function SubtitlesContent() {
const { data, isLoading } = useSubtitleProviders();
if (isLoading)
return (
<div className="space-y-6" role="status" aria-label="Loading settings">
<Skeleton className="h-8 w-48" />
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
<span className="sr-only">Loading settings</span>
</div>
);
const providers = data?.providers ?? [];
// Sort by known order, putting unknown providers at end
const sorted = [...providers].sort((a, b) => {
const ai = SUBTITLE_PROVIDER_ORDER.indexOf(a.provider_name);
const bi = SUBTITLE_PROVIDER_ORDER.indexOf(b.provider_name);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
return (
<div className="space-y-4">
<p className="text-muted-foreground max-w-3xl text-sm">
Configure external subtitle search providers. Credentials are stored securely and never
returned by the API.
</p>
<div className="max-w-2xl space-y-4">
{sorted.map((provider) => (
<SubtitleProviderCard key={provider.provider_name} config={provider} />
))}
{sorted.length === 0 && (
<div className="border-border bg-surface rounded-lg border px-5 py-4">
<p className="text-muted-foreground text-sm">No subtitle providers configured.</p>
</div>
)}
</div>
</div>
);
}
interface WatchProviderCredentials {
key: string;
displayName: string;
}
const WATCH_PROVIDER_CREDENTIALS: WatchProviderCredentials[] = [
{ key: "trakt", displayName: "Trakt" },
{ key: "simkl", displayName: "Simkl" },
];
function WatchProviderCredentialCard({ provider }: { provider: WatchProviderCredentials }) {
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const configured = new Set(sensitive?.configured ?? []);
const clientIdKey = `watchsync.${provider.key}.client_id`;
const clientSecretKey = `watchsync.${provider.key}.client_secret`;
function save() {
const updates = [];
if (clientId.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: clientIdKey, value: clientId }));
}
if (clientSecret.trim() !== "") {
updates.push(
updateSetting.mutateAsync({
key: clientSecretKey,
value: clientSecret,
}),
);
}
void Promise.all(updates).then(() => {
setClientId("");
setClientSecret("");
});
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">{provider.displayName}</h3>
<p className="text-muted-foreground text-xs">
OAuth credentials for profile connections.
</p>
</div>
<SubtitleCredentialStatus
configured={configured.has(clientIdKey) && configured.has(clientSecretKey)}
/>
</div>
<SettingField
label="Client ID"
type="password"
value={clientId}
onChange={setClientId}
sensitiveConfigured={configured.has(clientIdKey)}
hint="Leave blank to keep the current value."
/>
<SettingField
label="Client Secret"
type="password"
value={clientSecret}
onChange={setClientSecret}
sensitiveConfigured={configured.has(clientSecretKey)}
hint="Leave blank to keep the current value."
/>
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : `Save ${provider.displayName} Credentials`}
</Button>
</div>
);
}
function WatchProviderCredentialsContent() {
return (
<div className="space-y-4">
{WATCH_PROVIDER_CREDENTIALS.map((provider) => (
<WatchProviderCredentialCard key={provider.key} provider={provider} />
))}
</div>
);
}
function MDBListCredentialCard() {
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
@@ -384,7 +37,7 @@ function MDBListCredentialCard() {
.
</p>
</div>
<SubtitleCredentialStatus configured={configured} />
<CredentialStatus configured={configured} />
</div>
<SettingField
label="API Key"
@@ -401,138 +54,18 @@ function MDBListCredentialCard() {
);
}
function AISubtitleTranslationCard() {
const { data: settings } = useAdminServerSettings();
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
const apiKeyConfigured = new Set(sensitive?.configured ?? []).has("subtitle_ai.api_key");
const [enabled, setEnabled] = useState("false");
const [baseUrl, setBaseUrl] = useState("");
const [chatModel, setChatModel] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("2");
const [apiKey, setApiKey] = useState("");
// Hydrate the form from current server settings once loaded.
useEffect(() => {
if (!settings) return;
setEnabled(settings["subtitle_ai.enabled"] ?? "false");
setBaseUrl(settings["subtitle_ai.base_url"] ?? "https://api.openai.com");
setChatModel(settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini");
setMaxConcurrent(settings["subtitle_ai.max_concurrent_jobs"] ?? "2");
}, [settings]);
function save() {
const trimmedBaseUrl = baseUrl.trim();
const trimmedChatModel = chatModel.trim();
const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10);
// Don't let an admin persist a config that would break translation for
// everyone (a blank endpoint/model when enabled, or a bad concurrency value).
if (enabled === "true" && (trimmedBaseUrl === "" || trimmedChatModel === "")) {
toast.error("Base URL and chat model are required to enable AI translation.");
return;
}
if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) {
toast.error("Max concurrent jobs must be a positive whole number.");
return;
}
const updates = [
updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: enabled }),
updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: trimmedBaseUrl }),
updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: trimmedChatModel }),
updateSetting.mutateAsync({
key: "subtitle_ai.max_concurrent_jobs",
value: String(parsedMaxConcurrent),
}),
];
if (apiKey.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: "subtitle_ai.api_key", value: apiKey }));
}
void Promise.all(updates).then(() => setApiKey(""));
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">AI Subtitle Translation</h3>
<p className="text-muted-foreground text-xs">
On-demand subtitle translation via any OpenAI-compatible chat API (OpenAI, Groq, a local
Ollama server, ). Translated tracks are generated once on the server and served to
every client.
</p>
</div>
<SubtitleCredentialStatus configured={apiKeyConfigured} />
</div>
<SettingField
label="Enabled"
type="toggle"
value={enabled}
onChange={setEnabled}
hint="Show the “Translate with AI” action in the player."
/>
<SettingField
label="Base URL"
type="text"
value={baseUrl}
onChange={setBaseUrl}
hint="https://api.openai.com"
/>
<SettingField
label="Chat model"
type="text"
value={chatModel}
onChange={setChatModel}
hint="e.g. gpt-4o-mini, llama3.1"
/>
<SettingField
label="API Key"
type="password"
value={apiKey}
onChange={setApiKey}
sensitiveConfigured={apiKeyConfigured}
hint="Leave blank to keep current. Empty is fine for keyless local servers."
/>
<SettingField
label="Max concurrent jobs"
type="number"
value={maxConcurrent}
onChange={setMaxConcurrent}
hint="Caps simultaneous translations so they don't starve transcodes."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save AI Translation Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
</p>
</div>
</div>
);
}
export default function IntegrationsSettings() {
return (
<div className="flex h-full flex-col">
<div className="mb-6">
<h2 className="text-lg font-semibold">Integrations</h2>
<p className="text-muted-foreground text-sm">External services and provider credentials</p>
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Integrations</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
API keys for external services. Watch provider and subtitle credentials have their own
pages in the sidebar.
</p>
</div>
<div className="mb-8">
<WatchProviderCredentialsContent />
</div>
<div className="mb-8">
<MDBListCredentialCard />
</div>
<div className="mb-8">
<AISubtitleTranslationCard />
</div>
<SubtitlesContent />
<MDBListCredentialCard />
</div>
);
}
@@ -73,7 +73,7 @@ function DefaultsEditor({ value, onChange }: DefaultsEditorProps) {
if (overlays.length === 0) return null;
return (
<div key={category} className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{CATEGORY_META[category].title}
</div>
<div className="space-y-2">
@@ -1,316 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import PluginsSettings from "./PluginsSettings";
import Login from "@/pages/Login";
const useAdminPluginsMock = vi.fn();
const useAuthMock = vi.fn();
const installPluginMutateMock = vi.fn();
const savePluginConfigMutateMock = vi.fn();
const testPluginConfigMutateAsyncMock = vi.fn();
const capturedButtonProps: Array<Record<string, unknown>> = [];
vi.mock("@/components/ui/button", () => ({
Button: (props: Record<string, unknown>) => {
capturedButtonProps.push(props);
return props.children;
},
}));
vi.mock("@/hooks/queries/admin/plugins", () => ({
useAdminPlugins: () => useAdminPluginsMock(),
useCreatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }),
useUpdatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }),
useDeletePluginRepository: () => ({ mutate: vi.fn(), isPending: false }),
useInstallPlugin: () => ({ mutate: installPluginMutateMock, isPending: false }),
useUploadPlugin: () => ({ mutate: vi.fn(), isPending: false }),
usePluginUpload: () => ({ upload: vi.fn(), progress: null, isPending: false }),
useUpdatePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }),
useDeletePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }),
useSavePluginConfig: () => ({ mutate: savePluginConfigMutateMock, isPending: false }),
useTestPluginConfig: () => ({ mutateAsync: testPluginConfigMutateAsyncMock, isPending: false }),
useSavePluginAuthBinding: () => ({ mutate: vi.fn(), isPending: false }),
useSavePluginTaskBinding: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/queries/admin/subtitles", () => ({
useSubtitleProviders: () => ({ data: { providers: [] }, isLoading: false }),
useUpdateSubtitleProvider: () => ({ mutate: vi.fn(), isPending: false }),
useTestSubtitleProvider: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/useSettingsForm", () => ({
useSettingsForm: () => ({
isLoading: false,
getValue: () => "",
setValue: vi.fn(),
dirtyCount: 0,
save: vi.fn(),
discard: vi.fn(),
isSaving: false,
restartRequired: false,
sensitiveConfigured: [],
}),
}));
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => useAuthMock(),
}));
vi.mock("@/hooks/useServerBranding", () => ({
useServerBranding: () => ({
serverName: "Silo",
loginTitle: "Sign in",
loginSubtitle: "",
loginBadge: "",
heroImageUrl: "",
}),
}));
describe("PluginsSettings", () => {
it("installs catalog plugins by repository identity instead of archive url", () => {
capturedButtonProps.length = 0;
installPluginMutateMock.mockReset();
useAdminPluginsMock.mockReturnValue({
repositories: [],
catalog: [
{
repository_id: 7,
plugin_id: "example.remote",
version: "1.2.3",
archive_url: "https://plugins.example.test/example.remote.zip",
},
],
installations: [],
isLoading: false,
});
renderToStaticMarkup(
<MemoryRouter>
<PluginsSettings />
</MemoryRouter>,
);
const installButton = capturedButtonProps.find((props) => props.children === "Install");
expect(installButton).toBeTruthy();
expect(typeof installButton?.onClick).toBe("function");
(installButton?.onClick as () => void)();
expect(installPluginMutateMock).toHaveBeenCalledWith({
repository_id: 7,
plugin_id: "example.remote",
version: "1.2.3",
});
});
it("renders repositories, catalog entries, installations, and plugin-hosted admin links", () => {
useAdminPluginsMock.mockReturnValue({
repositories: [
{ id: 1, url: "https://plugins.example.test/index.json", display_name: "Example Repo" },
],
catalog: [
{
repository_id: 1,
plugin_id: "example.remote",
version: "1.2.3",
archive_url: "https://plugins.example.test/example.remote.zip",
},
],
installations: [
{
id: 11,
plugin_id: "example.remote",
version: "1.2.3",
enabled: true,
legacy_metadata_import_types: ["tmdb", "tvdb"],
routes: [
{
id: "admin-page",
method: "GET",
path: "/admin",
access: "admin",
navigable: true,
navigation_label: "Admin Console",
navigation_kind: "admin",
static_asset: false,
},
],
},
],
isLoading: false,
});
const markup = renderToStaticMarkup(
<MemoryRouter>
<PluginsSettings />
</MemoryRouter>,
);
expect(markup).toContain("Repositories");
expect(markup).toContain("Example Repo");
expect(markup).toContain("Catalog");
expect(markup).toContain("example.remote");
expect(markup).toContain("Installed Plugins");
expect(markup).toContain("Admin Console");
expect(markup).not.toContain("Import legacy");
});
it("does not restrict manual plugin uploads to zip files", () => {
useAdminPluginsMock.mockReturnValue({
repositories: [],
catalog: [],
installations: [],
isLoading: false,
});
const markup = renderToStaticMarkup(
<MemoryRouter>
<PluginsSettings />
</MemoryRouter>,
);
expect(markup).toContain("Upload package");
expect(markup).not.toContain('accept=".zip"');
});
it("renders admin form labels instead of raw json schema blobs", () => {
useAdminPluginsMock.mockReturnValue({
repositories: [],
catalog: [],
installations: [
{
id: 11,
plugin_id: "silo.tmdb",
version: "1.0.0",
enabled: true,
global_configs: [{ key: "connection", value: { api_key: "secret-value" } }],
global_config_schema: [
{
key: "connection",
title: "Connection",
description: "TMDB credentials",
json_schema:
'{"type":"object","properties":{"api_key":{"type":"string"}},"required":["api_key"],"additionalProperties":false}',
required: true,
admin_form: {
fields: [
{
key: "api_key",
label: "TMDB API Key",
description: "Paste your TMDB API key.",
control: "PASSWORD",
required: true,
},
],
},
},
],
},
],
isLoading: false,
});
const markup = renderToStaticMarkup(
<MemoryRouter>
<PluginsSettings />
</MemoryRouter>,
);
expect(markup).toContain("TMDB API Key");
expect(markup).not.toContain("{&quot;api_key&quot;:&quot;secret-value&quot;}");
});
it("only shows plugin connection checks for metadata provider installations", () => {
capturedButtonProps.length = 0;
useAdminPluginsMock.mockReturnValue({
repositories: [],
catalog: [],
installations: [
{
id: 11,
plugin_id: "silo.tmdb",
version: "1.0.0",
enabled: true,
capabilities: [{ type: "metadata_provider.v1", id: "tmdb", display_name: "TMDB" }],
global_configs: [{ key: "connection", value: { api_key: "secret-value" } }],
global_config_schema: [
{
key: "connection",
title: "Connection",
description: "TMDB credentials",
json_schema:
'{"type":"object","properties":{"api_key":{"type":"string"}},"required":["api_key"],"additionalProperties":false}',
required: true,
admin_form: {
fields: [
{
key: "api_key",
label: "TMDB API Key",
description: "Paste your TMDB API key.",
control: "PASSWORD",
required: true,
},
],
},
},
],
},
{
id: 12,
plugin_id: "silo.jobs",
version: "1.0.0",
enabled: true,
capabilities: [{ type: "scheduled_task.v1", id: "refresh", display_name: "Refresh" }],
global_configs: [],
global_config_schema: [
{
key: "task",
title: "Task",
json_schema: '{"type":"object","properties":{"enabled":{"type":"boolean"}}}',
required: false,
},
],
},
],
isLoading: false,
});
renderToStaticMarkup(
<MemoryRouter>
<PluginsSettings />
</MemoryRouter>,
);
const checkButtons = capturedButtonProps.filter(
(props) => props.children === "Check Connection",
);
expect(checkButtons).toHaveLength(1);
});
it("shows credential providers on the login screen", () => {
useAuthMock.mockReturnValue({
login: vi.fn(),
user: null,
loading: false,
setupLoading: false,
setupRequired: false,
providers: [
{ id: "local", display_name: "Local", mode: "credentials", default: true },
{ id: "plugin:41:ldap", display_name: "LDAP", mode: "credentials", default: false },
],
});
const markup = renderToStaticMarkup(
<MemoryRouter>
<Login />
</MemoryRouter>,
);
expect(markup).toContain("Sign in with");
expect(markup).toContain('role="combobox"');
});
});
@@ -1,391 +0,0 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { PluginConfigForm } from "@/components/admin/plugins/PluginConfigForm";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import type { PluginInstallation } from "@/api/types";
import { pluginRouteHref } from "@/lib/pluginRouteHref";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import {
useAdminPlugins,
useCreatePluginRepository,
useDeletePluginInstallation,
useDeletePluginRepository,
useInstallPlugin,
usePluginUpload,
useSavePluginAuthBinding,
useSavePluginConfig,
useSavePluginTaskBinding,
useTestPluginConfig,
useUpdatePluginInstallation,
useUpdatePluginRepository,
} from "@/hooks/queries/admin/plugins";
function PluginInstallationCard({ installation }: { installation: PluginInstallation }) {
const updateInstallation = useUpdatePluginInstallation();
const deleteInstallation = useDeletePluginInstallation();
const saveConfig = useSavePluginConfig();
const testConfig = useTestPluginConfig();
const saveAuthBinding = useSavePluginAuthBinding();
const saveTaskBinding = useSavePluginTaskBinding();
const capabilities = installation.capabilities ?? [];
const globalConfigs = installation.global_configs ?? [];
const globalConfigSchema = installation.global_config_schema ?? [];
const authBindings = installation.auth_bindings ?? [];
const taskBindings = installation.task_bindings ?? [];
const routes = installation.routes ?? [];
const [enabledOverride, setEnabledOverride] = useState<boolean | null>(null);
const adminRoutes = routes.filter(
(route) => route.navigable && route.navigation_kind === "admin",
);
const authCapabilities = capabilities.filter(
(capability) => capability.type === "auth_provider.v1",
);
const taskCapabilities = capabilities.filter(
(capability) => capability.type === "scheduled_task.v1",
);
const supportsConnectionTest = capabilities.some(
(capability) => capability.type === "metadata_provider.v1",
);
const enabled = enabledOverride ?? installation.enabled;
return (
<section className="surface-panel-subtle space-y-5 rounded-xl p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">{installation.plugin_id}</h3>
<Badge variant="secondary">{installation.version}</Badge>
<Badge variant={enabled ? "outline" : "destructive"}>
{enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<div className="text-muted-foreground flex flex-wrap gap-2 text-xs">
{capabilities.map((capability) => (
<Badge key={`${capability.type}:${capability.id}`} variant="secondary">
{capability.display_name || capability.id}
</Badge>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch checked={enabled} onCheckedChange={setEnabledOverride} />
<Label>Enabled</Label>
</div>
<Button
variant="outline"
onClick={() => updateInstallation.mutate({ id: installation.id, body: { enabled } })}
>
Save
</Button>
<Button variant="destructive" onClick={() => deleteInstallation.mutate(installation.id)}>
Remove
</Button>
</div>
</div>
{globalConfigSchema.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">Global Config</h4>
<div className="grid gap-4 xl:grid-cols-2">
{globalConfigSchema.map((schema) => (
<PluginConfigForm
key={schema.key}
schema={schema}
value={globalConfigs.find((entry) => entry.key === schema.key)?.value}
isSaving={saveConfig.isPending}
isTesting={testConfig.isPending}
onSave={(key, nextValue) =>
saveConfig.mutate({ id: installation.id, body: { key, value: nextValue } })
}
onTest={
supportsConnectionTest
? (key, nextValue) =>
testConfig.mutateAsync({
id: installation.id,
body: { key, value: nextValue },
})
: undefined
}
/>
))}
</div>
</div>
)}
{authCapabilities.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">Auth Providers</h4>
{authCapabilities.map((capability, index) => {
const binding = authBindings.find((entry) => entry.capability_id === capability.id);
return (
<div
key={capability.id}
className="flex flex-col gap-3 rounded-md border p-3 lg:flex-row lg:items-center lg:justify-between"
>
<div>
<p className="font-medium">{capability.display_name || capability.id}</p>
<p className="text-muted-foreground text-xs">{capability.id}</p>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
defaultChecked={binding?.enabled ?? false}
onChange={(event) =>
saveAuthBinding.mutate({
id: installation.id,
body: {
capability_id: capability.id,
enabled: event.target.checked,
display_order: binding?.display_order ?? index + 1,
auto_provision: binding?.auto_provision ?? true,
default_login: binding?.default_login ?? false,
},
})
}
/>
Enabled
</label>
</div>
</div>
);
})}
</div>
)}
{taskCapabilities.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">Scheduled Tasks</h4>
{taskCapabilities.map((capability) => {
const binding = taskBindings.find((entry) => entry.capability_id === capability.id);
return (
<div key={capability.id} className="space-y-3 rounded-md border p-3">
<div>
<p className="font-medium">{capability.display_name || capability.id}</p>
<p className="text-muted-foreground text-xs">{capability.id}</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() =>
saveTaskBinding.mutate({
id: installation.id,
capabilityId: capability.id,
body: {
enabled: binding?.enabled ?? true,
trigger: binding?.trigger ?? { type: "startup" },
},
})
}
>
Save task binding
</Button>
</div>
);
})}
</div>
)}
{adminRoutes.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Plugin-hosted Admin Pages</h4>
<div className="flex flex-wrap gap-2">
{adminRoutes.map((route) => {
const href = pluginRouteHref(installation.id, route.path);
return (
<a
key={route.id}
href={href}
onClick={(e) => {
e.preventDefault();
void navigateToPluginRoute(href);
}}
className="inline-flex items-center rounded-md border px-3 py-2 text-sm"
>
{route.navigation_label || route.path}
</a>
);
})}
</div>
</div>
)}
</section>
);
}
export default function PluginsSettings() {
const { repositories, catalog, installations, isLoading } = useAdminPlugins();
const createRepository = useCreatePluginRepository();
const updateRepository = useUpdatePluginRepository();
const deleteRepository = useDeletePluginRepository();
const installPlugin = useInstallPlugin();
const uploadPlugin = usePluginUpload();
const [repositoryName, setRepositoryName] = useState("");
const [repositoryURL, setRepositoryURL] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
function handleRepositorySubmit(event: FormEvent) {
event.preventDefault();
if (!repositoryName.trim() || !repositoryURL.trim()) {
return;
}
createRepository.mutate({
display_name: repositoryName.trim(),
url: repositoryURL.trim(),
enabled: true,
});
setRepositoryName("");
setRepositoryURL("");
}
function handleUploadSubmit(event: FormEvent) {
event.preventDefault();
if (!uploadFile) {
return;
}
uploadPlugin.upload(uploadFile, { onSuccess: () => setUploadFile(null) });
}
if (isLoading) {
return (
<div className="space-y-6" role="status" aria-label="Loading settings">
<Skeleton className="h-8 w-48" />
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
</div>
<span className="sr-only">Loading settings</span>
</div>
);
}
return (
<div className="space-y-8">
<section className="surface-panel-subtle space-y-4 rounded-xl p-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Repositories</h3>
<Badge variant="secondary">{repositories.length}</Badge>
</div>
<form onSubmit={handleRepositorySubmit} className="grid gap-3 lg:grid-cols-[1fr_1fr_auto]">
<Input
value={repositoryName}
onChange={(event) => setRepositoryName(event.target.value)}
placeholder="Repository name"
/>
<Input
value={repositoryURL}
onChange={(event) => setRepositoryURL(event.target.value)}
placeholder="https://plugins.example.test/index.json"
/>
<Button type="submit">Add repository</Button>
</form>
<div className="space-y-2">
{repositories.map((repository) => (
<div
key={repository.id}
className="flex flex-col gap-3 rounded-md border p-3 lg:flex-row lg:items-center lg:justify-between"
>
<div className="space-y-1">
<p className="font-medium">{repository.display_name}</p>
<p className="text-muted-foreground text-xs">{repository.url}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
updateRepository.mutate({
id: repository.id,
body: { enabled: !repository.enabled },
})
}
>
{repository.enabled ? "Disable" : "Enable"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteRepository.mutate(repository.id)}
>
Remove
</Button>
</div>
</div>
))}
</div>
</section>
<section className="surface-panel-subtle space-y-4 rounded-xl p-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Catalog</h3>
<Badge variant="secondary">{catalog.length}</Badge>
</div>
<form
onSubmit={handleUploadSubmit}
className="flex flex-col gap-3 rounded-md border p-3 lg:flex-row lg:items-center"
>
<input
type="file"
disabled={uploadPlugin.isPending}
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
/>
<Button type="submit" variant="outline" disabled={!uploadFile || uploadPlugin.isPending}>
{uploadPlugin.isPending ? "Uploading..." : "Upload package"}
</Button>
</form>
{uploadPlugin.progress !== null && (
<Progress value={uploadPlugin.progress} aria-label="Plugin upload progress" />
)}
<div className="space-y-2">
{catalog.map((entry) => (
<div
key={`${entry.plugin_id}:${entry.version}`}
className="flex flex-col gap-3 rounded-md border p-3 lg:flex-row lg:items-center lg:justify-between"
>
<div className="space-y-1">
<p className="font-medium">{entry.plugin_id}</p>
<p className="text-muted-foreground text-xs">{entry.version}</p>
</div>
<Button
size="sm"
onClick={() =>
installPlugin.mutate({
repository_id: entry.repository_id,
plugin_id: entry.plugin_id,
version: entry.version,
})
}
>
Install
</Button>
</div>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Installed Plugins</h3>
<Badge variant="secondary">{installations.length}</Badge>
</div>
<div className="space-y-4">
{installations.map((installation) => (
<PluginInstallationCard key={installation.id} installation={installation} />
))}
</div>
</section>
</div>
);
}
@@ -0,0 +1,390 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import {
useSubtitleProviders,
useUpdateSubtitleProvider,
useTestSubtitleProvider,
} from "@/hooks/queries/admin/subtitles";
import {
useAdminSensitiveStatus,
useAdminServerSettings,
useUpdateServerSetting,
} from "@/hooks/queries/admin/settings";
import type { SubtitleProviderConfig } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Eye, EyeOff } from "lucide-react";
import { CredentialStatus } from "./CredentialStatus";
import { SettingField } from "./SettingField";
// ============================================================================
// Search providers
// ============================================================================
const SUBTITLE_PROVIDER_NAMES: Record<string, string> = {
opensubtitles: "OpenSubtitles",
subdl: "SubDL",
subsource: "SubSource",
};
interface SubtitleProviderFormState {
enabled: boolean;
api_key: string;
username: string;
password: string;
showApiKey: boolean;
}
interface SubtitleTestResult {
success: boolean;
error?: string;
}
function defaultSubtitleFormState(config: SubtitleProviderConfig): SubtitleProviderFormState {
return {
enabled: config.enabled,
api_key: "",
username: "",
password: "",
showApiKey: false,
};
}
function SubtitleProviderCard({ config }: { config: SubtitleProviderConfig }) {
const [form, setForm] = useState<SubtitleProviderFormState>(() =>
defaultSubtitleFormState(config),
);
const [testResult, setTestResult] = useState<SubtitleTestResult | null>(null);
const updateProvider = useUpdateSubtitleProvider();
const testProvider = useTestSubtitleProvider();
useEffect(() => {
setForm((prev) => ({
...prev,
enabled: config.enabled,
}));
}, [config.enabled]);
const providerName = config.provider_name;
const displayName = SUBTITLE_PROVIDER_NAMES[providerName] ?? providerName;
const isOpenSubtitles = providerName === "opensubtitles";
function handleSave() {
updateProvider.mutate({
provider: providerName,
config: {
enabled: form.enabled,
...(isOpenSubtitles
? { username: form.username, password: form.password }
: { api_key: form.api_key }),
},
});
}
function handleTest() {
setTestResult(null);
testProvider.mutate(providerName, {
onSuccess: (result) => {
setTestResult({ success: result.success, error: result.error });
},
onError: (err) => {
setTestResult({
success: false,
error: err instanceof Error ? err.message : "Test failed",
});
},
});
}
return (
<div className="border-border bg-surface space-y-4 rounded-lg border px-5 py-4">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold">{displayName}</span>
<CredentialStatus
configured={isOpenSubtitles ? config.has_credentials : config.has_api_key}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`${providerName}-enabled`} className="text-sm font-medium">
{form.enabled ? "Enabled" : "Disabled"}
</Label>
<Switch
id={`${providerName}-enabled`}
checked={form.enabled}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</div>
{/* Credentials: username/password for OpenSubtitles, API key for others */}
{isOpenSubtitles ? (
<>
<div className="space-y-1">
<Label htmlFor={`${providerName}-username`} className="text-sm font-medium">
Username
</Label>
<Input
id={`${providerName}-username`}
type="text"
placeholder={
config.has_credentials ? "Leave blank to keep current" : "OpenSubtitles username"
}
value={form.username}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`${providerName}-password`} className="text-sm font-medium">
Password
</Label>
<Input
id={`${providerName}-password`}
type="password"
placeholder={
config.has_credentials ? "Leave blank to keep current" : "OpenSubtitles password"
}
value={form.password}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
</div>
</>
) : (
<div className="space-y-1">
<Label htmlFor={`${providerName}-api-key`} className="text-sm font-medium">
API Key
</Label>
<div className="flex items-center gap-2">
<Input
id={`${providerName}-api-key`}
type={form.showApiKey ? "text" : "password"}
placeholder={config.has_api_key ? "Leave blank to keep current" : "Enter API key"}
value={form.api_key}
onChange={(e) => setForm((prev) => ({ ...prev, api_key: e.target.value }))}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => setForm((prev) => ({ ...prev, showApiKey: !prev.showApiKey }))}
>
{form.showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-1">
<Button variant="outline" onClick={handleTest} disabled={testProvider.isPending}>
{testProvider.isPending ? "Testing..." : "Test Connection"}
</Button>
<Button onClick={handleSave} disabled={updateProvider.isPending}>
{updateProvider.isPending ? "Saving..." : "Save"}
</Button>
{testResult !== null && (
<span className={`text-sm ${testResult.success ? "text-green-500" : "text-red-500"}`}>
{testResult.success
? "Connection successful"
: (testResult.error ?? "Connection failed")}
</span>
)}
</div>
</div>
);
}
const SUBTITLE_PROVIDER_ORDER = ["opensubtitles", "subdl", "subsource"];
function SearchProvidersContent() {
const { data, isLoading } = useSubtitleProviders();
if (isLoading)
return (
<div className="space-y-6" role="status" aria-label="Loading settings">
<Skeleton className="h-8 w-48" />
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
<span className="sr-only">Loading settings</span>
</div>
);
const providers = data?.providers ?? [];
// Sort by known order, putting unknown providers at end
const sorted = [...providers].sort((a, b) => {
const ai = SUBTITLE_PROVIDER_ORDER.indexOf(a.provider_name);
const bi = SUBTITLE_PROVIDER_ORDER.indexOf(b.provider_name);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
return (
<div className="space-y-4">
<p className="text-muted-foreground max-w-3xl text-sm">
Configure external subtitle search providers. Credentials are stored securely and never
returned by the API.
</p>
<div className="max-w-2xl space-y-4">
{sorted.map((provider) => (
<SubtitleProviderCard key={provider.provider_name} config={provider} />
))}
{sorted.length === 0 && (
<div className="border-border bg-surface rounded-lg border px-5 py-4">
<p className="text-muted-foreground text-sm">No subtitle providers configured.</p>
</div>
)}
</div>
</div>
);
}
// ============================================================================
// AI translation
// ============================================================================
function AISubtitleTranslationCard() {
const { data: settings } = useAdminServerSettings();
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
const apiKeyConfigured = new Set(sensitive?.configured ?? []).has("subtitle_ai.api_key");
const [enabled, setEnabled] = useState("false");
const [baseUrl, setBaseUrl] = useState("");
const [chatModel, setChatModel] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("2");
const [apiKey, setApiKey] = useState("");
// Hydrate the form from current server settings once loaded.
useEffect(() => {
if (!settings) return;
setEnabled(settings["subtitle_ai.enabled"] ?? "false");
setBaseUrl(settings["subtitle_ai.base_url"] ?? "https://api.openai.com");
setChatModel(settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini");
setMaxConcurrent(settings["subtitle_ai.max_concurrent_jobs"] ?? "2");
}, [settings]);
function save() {
const trimmedBaseUrl = baseUrl.trim();
const trimmedChatModel = chatModel.trim();
const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10);
// Don't let an admin persist a config that would break translation for
// everyone (a blank endpoint/model when enabled, or a bad concurrency value).
if (enabled === "true" && (trimmedBaseUrl === "" || trimmedChatModel === "")) {
toast.error("Base URL and chat model are required to enable AI translation.");
return;
}
if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) {
toast.error("Max concurrent jobs must be a positive whole number.");
return;
}
const updates = [
updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: enabled }),
updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: trimmedBaseUrl }),
updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: trimmedChatModel }),
updateSetting.mutateAsync({
key: "subtitle_ai.max_concurrent_jobs",
value: String(parsedMaxConcurrent),
}),
];
if (apiKey.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: "subtitle_ai.api_key", value: apiKey }));
}
void Promise.all(updates).then(() => setApiKey(""));
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">AI Subtitle Translation</h3>
<p className="text-muted-foreground text-xs">
On-demand subtitle translation via any OpenAI-compatible chat API (OpenAI, Groq, a local
Ollama server, ). Translated tracks are generated once on the server and served to
every client.
</p>
</div>
<CredentialStatus configured={apiKeyConfigured} />
</div>
<SettingField
label="Enabled"
type="toggle"
value={enabled}
onChange={setEnabled}
hint="Show the “Translate with AI” action in the player."
/>
<SettingField
label="Base URL"
type="text"
value={baseUrl}
onChange={setBaseUrl}
hint="https://api.openai.com"
/>
<SettingField
label="Chat model"
type="text"
value={chatModel}
onChange={setChatModel}
hint="e.g. gpt-4o-mini, llama3.1"
/>
<SettingField
label="API Key"
type="password"
value={apiKey}
onChange={setApiKey}
sensitiveConfigured={apiKeyConfigured}
hint="Leave blank to keep current. Empty is fine for keyless local servers."
/>
<SettingField
label="Max concurrent jobs"
type="number"
value={maxConcurrent}
onChange={setMaxConcurrent}
hint="Caps simultaneous translations so they don't starve transcodes."
/>
<div className="pt-2">
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : "Save AI Translation Settings"}
</Button>
<p className="text-muted-foreground mt-2 text-xs">
Changes take effect after a server restart.
</p>
</div>
</div>
);
}
export default function SubtitlesSettings() {
return (
<div className="flex h-full flex-col">
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Subtitles</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Search providers for downloading subtitles and AI translation for generating new language
tracks.
</p>
</div>
<div className="space-y-8">
<SearchProvidersContent />
<AISubtitleTranslationCard />
</div>
</div>
);
}
@@ -0,0 +1,100 @@
import { useState } from "react";
import { useAdminSensitiveStatus, useUpdateServerSetting } from "@/hooks/queries/admin/settings";
import { Button } from "@/components/ui/button";
import { CredentialStatus } from "./CredentialStatus";
import { SettingField } from "./SettingField";
interface WatchProviderCredentials {
key: string;
displayName: string;
}
const WATCH_PROVIDER_CREDENTIALS: WatchProviderCredentials[] = [
{ key: "trakt", displayName: "Trakt" },
{ key: "simkl", displayName: "Simkl" },
];
function WatchProviderCredentialCard({ provider }: { provider: WatchProviderCredentials }) {
const { data: sensitive } = useAdminSensitiveStatus();
const updateSetting = useUpdateServerSetting();
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const configured = new Set(sensitive?.configured ?? []);
const clientIdKey = `watchsync.${provider.key}.client_id`;
const clientSecretKey = `watchsync.${provider.key}.client_secret`;
function save() {
const updates = [];
if (clientId.trim() !== "") {
updates.push(updateSetting.mutateAsync({ key: clientIdKey, value: clientId }));
}
if (clientSecret.trim() !== "") {
updates.push(
updateSetting.mutateAsync({
key: clientSecretKey,
value: clientSecret,
}),
);
}
void Promise.all(updates).then(() => {
setClientId("");
setClientSecret("");
});
}
return (
<div className="border-border bg-surface max-w-2xl rounded-lg border px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">{provider.displayName}</h3>
<p className="text-muted-foreground text-xs">
OAuth credentials for profile connections.
</p>
</div>
<CredentialStatus
configured={configured.has(clientIdKey) && configured.has(clientSecretKey)}
/>
</div>
<SettingField
label="Client ID"
type="password"
value={clientId}
onChange={setClientId}
sensitiveConfigured={configured.has(clientIdKey)}
hint="Leave blank to keep the current value."
/>
<SettingField
label="Client Secret"
type="password"
value={clientSecret}
onChange={setClientSecret}
sensitiveConfigured={configured.has(clientSecretKey)}
hint="Leave blank to keep the current value."
/>
<Button type="button" onClick={save} disabled={updateSetting.isPending}>
{updateSetting.isPending ? "Saving..." : `Save ${provider.displayName} Credentials`}
</Button>
</div>
);
}
export default function WatchProvidersSettings() {
return (
<div className="flex h-full flex-col">
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Watch Providers</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
OAuth credentials for watch history and scrobbling services. Users connect their own
accounts from their profile settings once a provider is configured here.
</p>
</div>
<div className="space-y-4">
{WATCH_PROVIDER_CREDENTIALS.map((provider) => (
<WatchProviderCredentialCard key={provider.key} provider={provider} />
))}
</div>
</div>
);
}