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:
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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("{"api_key":"secret-value"}");
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user