feat(plex): P0 admin Config UI — connection test + library picker

- ConfigPanel 'plex' group auto-renders; adds a Test-connection button + a
  library-picker (checked sections write plex_libraries, applied on Save)
- api.testPlex + PlexTestResult/PlexSection types
- i18n config namespace: plex group + 7 fields + test strings (en/hu/de);
  also fills the previously-missing 'downloads' group label
This commit is contained in:
npeter83 2026-07-05 01:35:08 +02:00
parent a88254c841
commit 5684d6c406
5 changed files with 215 additions and 9 deletions

View file

@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api";
import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
@ -14,7 +14,7 @@ import { DraftSaveBar } from "./ui/DraftSaveBar";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch"];
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() {
@ -93,6 +93,29 @@ export default function ConfigPanel() {
onError: () => notify({ level: "error", message: t("config.testFailed") }),
});
// Plex "Test connection": verifies the server URL + token and returns the library sections,
// which drive the library-picker below (checked sections are written into the plex_libraries
// draft as a comma-separated list of section keys, applied on Save).
const [plexResult, setPlexResult] = useState<PlexTestResult | null>(null);
const testPlex = useMutation({
mutationFn: () => api.testPlex(),
onSuccess: (r) => {
setPlexResult(r);
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
},
onError: () => {
setPlexResult(null);
notify({ level: "error", message: t("config.plexTestFailed") });
},
});
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const togglePlexLib = (key: string) => {
const next = plexLibs.includes(key)
? plexLibs.filter((k) => k !== key)
: [...plexLibs, key];
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
};
if (q.isLoading || !data) {
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
}
@ -149,6 +172,54 @@ export default function ConfigPanel() {
</Tooltip>
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<button
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</button>
</Tooltip>
{dirty && <p className="text-[11px] text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
<p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
<span>{s.title}</span>
<span className="text-[11px] text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
)}
</div>
)}
</div>
)}