- 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
334 lines
No EOL
14 KiB
TypeScript
334 lines
No EOL
14 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
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";
|
|
import { Switch } from "./ui/form";
|
|
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
|
|
|
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
|
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
|
// 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", "downloads", "plex"];
|
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
|
|
|
export default function ConfigPanel() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig });
|
|
const data = q.data;
|
|
|
|
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
|
|
const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]);
|
|
// Baseline draft from the server: non-secrets show their effective value; secrets start empty
|
|
// (write-only — we never load them back).
|
|
const baseline = useMemo(() => {
|
|
const m: Record<string, string> = {};
|
|
for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? "");
|
|
return m;
|
|
}, [items]);
|
|
|
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
|
// Secrets the admin marked to reset-to-default on the next Save (can't be expressed by
|
|
// clearing the field, since an empty secret field means "leave unchanged").
|
|
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
|
const [saveState, setSaveState] = useState<SaveState>("idle");
|
|
// One tab per config group (persisted). Called before the loading guard so hook order is
|
|
// stable; the active id is clamped to a real group at render time once data has loaded.
|
|
const [tab, setTab] = usePersistedTab("siftlode.configTab");
|
|
|
|
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
|
|
useEffect(() => {
|
|
setDraft(baseline);
|
|
setSecretReset({});
|
|
}, [baseline]);
|
|
|
|
const isDirty = (it: ConfigItem): boolean => {
|
|
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
|
return (draft[it.key] ?? "") !== (baseline[it.key] ?? "");
|
|
};
|
|
const dirtyKeys = items.filter(isDirty).map((i) => i.key);
|
|
const dirty = dirtyKeys.length > 0;
|
|
|
|
async function save() {
|
|
setSaveState("saving");
|
|
try {
|
|
for (const key of dirtyKeys) {
|
|
const it = byKey[key];
|
|
const raw = draft[key] ?? "";
|
|
if (it.secret) {
|
|
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
|
else if (raw.trim()) await api.setConfig(key, raw);
|
|
} else if (it.type === "bool") {
|
|
await api.setConfig(key, raw === "true");
|
|
} else if (raw === "") {
|
|
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
|
} else {
|
|
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
|
}
|
|
}
|
|
await qc.invalidateQueries({ queryKey: ["admin-config"] });
|
|
setSaveState("saved");
|
|
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
|
} catch {
|
|
setSaveState("error");
|
|
notify({ level: "error", message: t("config.saveFailed") });
|
|
}
|
|
}
|
|
|
|
function discard() {
|
|
setDraft(baseline);
|
|
setSecretReset({});
|
|
setSaveState("idle");
|
|
}
|
|
|
|
const testEmail = useMutation({
|
|
mutationFn: () => api.testEmail(),
|
|
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
|
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>;
|
|
}
|
|
|
|
const groupKeys = Object.keys(data.groups).sort(
|
|
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
|
|
);
|
|
// Clamp the persisted tab to a real group (the registry can change between sessions). The
|
|
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
|
|
// never silently lost behind the global Save bar.
|
|
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
|
|
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
|
|
const groupTabs = groupKeys.map((g) => ({
|
|
id: g,
|
|
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
|
|
}));
|
|
|
|
return (
|
|
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
|
|
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
|
|
|
|
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
|
|
|
|
{activeGroup && (
|
|
<div className="glass rounded-2xl p-4 mb-4">
|
|
<div className="divide-y divide-border/60">
|
|
{data.groups[activeGroup].map((item) => (
|
|
<Field
|
|
key={item.key}
|
|
item={item}
|
|
value={draft[item.key] ?? ""}
|
|
secretsManageable={data.secrets_manageable}
|
|
pendingSecretReset={!!secretReset[item.key]}
|
|
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
|
|
onResetField={() => {
|
|
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
|
|
else setDraft((d) => ({ ...d, [item.key]: "" }));
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{activeGroup === "email" && (
|
|
<div className="mt-3 pt-3 border-t border-border/60">
|
|
<Tooltip hint={t("config.testEmailHint")}>
|
|
<button
|
|
onClick={() => testEmail.mutate()}
|
|
disabled={testEmail.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"
|
|
>
|
|
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
|
|
{t("config.testEmail")}
|
|
</button>
|
|
</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>
|
|
)}
|
|
|
|
<DraftSaveBar
|
|
variant="floating"
|
|
dirty={dirty}
|
|
state={saveState}
|
|
onSave={save}
|
|
onDiscard={discard}
|
|
labels={{
|
|
saved: t("config.saved"),
|
|
failed: t("config.saveFailed"),
|
|
unsaved: t("config.unsaved"),
|
|
discard: t("config.discard"),
|
|
save: t("config.save"),
|
|
saving: t("config.saving"),
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
item,
|
|
value,
|
|
secretsManageable,
|
|
pendingSecretReset,
|
|
onChange,
|
|
onResetField,
|
|
}: {
|
|
item: ConfigItem;
|
|
value: string;
|
|
secretsManageable: boolean;
|
|
pendingSecretReset: boolean;
|
|
onChange: (v: string) => void;
|
|
onResetField: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isNum = item.type === "int";
|
|
const isBool = item.type === "bool";
|
|
const secretDisabled = item.secret && !secretsManageable;
|
|
|
|
// Status line: never reveal a secret's value.
|
|
let status: string;
|
|
if (item.secret) {
|
|
status = secretDisabled
|
|
? t("config.secretsDisabled")
|
|
: pendingSecretReset
|
|
? t("config.willReset")
|
|
: item.is_set
|
|
? t("config.secretSet")
|
|
: item.default_is_set
|
|
? t("config.secretEnv")
|
|
: t("config.secretUnset");
|
|
} else {
|
|
status = item.is_set ? t("config.overridden") : t("config.usingDefault");
|
|
}
|
|
|
|
// Reset affordance shows when a DB override exists: clears a non-secret to its default, or
|
|
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
|
|
// its own control — storing its value is equivalent to the default, so no reset link.
|
|
const showReset = item.is_set && !isBool;
|
|
|
|
return (
|
|
<div className="flex items-start justify-between gap-3 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{t(`config.fields.${item.key}.hint`, "")}
|
|
</p>
|
|
<p className="text-[11px] text-muted mt-1">{status}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
|
{isBool ? (
|
|
<Switch
|
|
label={t(`config.fields.${item.key}.label`, item.key)}
|
|
checked={value === "true"}
|
|
onChange={(v) => onChange(v ? "true" : "false")}
|
|
/>
|
|
) : (
|
|
<input
|
|
type={item.secret ? "password" : isNum ? "number" : "text"}
|
|
value={value}
|
|
disabled={secretDisabled || (item.secret && pendingSecretReset)}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
min={isNum && item.min != null ? item.min : undefined}
|
|
max={isNum && item.max != null ? item.max : undefined}
|
|
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
|
aria-label={t(`config.fields.${item.key}.label`, item.key)}
|
|
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
|
|
/>
|
|
)}
|
|
{showReset && !secretDisabled && (
|
|
<Tooltip hint={t("config.resetHint")}>
|
|
<button
|
|
onClick={onResetField}
|
|
className={`inline-flex items-center gap-1 text-xs transition ${
|
|
item.secret && pendingSecretReset
|
|
? "text-accent"
|
|
: "text-muted hover:text-fg"
|
|
}`}
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
{t("config.reset")}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |