Files
siftlode/frontend/src/components/ConfigPanel.tsx
T
peter 34297b696f refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.

The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.

Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
2026-07-27 22:10:03 +02:00

393 lines
16 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
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 { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
// 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",
"audit",
"plex",
];
export default function ConfigPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.adminConfig(), 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");
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
// refetch does NOT (see the effect below).
const [reseedToken, setReseedToken] = useState(0);
// 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(LS.configTab);
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
useEffect(() => {
setDraft(baseline);
setSecretReset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
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];
if (!it) continue; // dirtyKeys are all config-item keys; skip if somehow not found
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 === "") {
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else 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: qk.adminConfig() });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
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) => {
// An empty list means "all libraries" — every box renders checked. Unchecking one from that
// state must select all-except-this, so expand "all" to the explicit section set first;
// otherwise the toggle would ADD the key and leave it as the ONLY selected library.
const allKeys = plexResult?.sections?.map((s) => s.key) ?? [];
const current = plexLibs.length === 0 ? allKeys : plexLibs;
const next = current.includes(key) ? current.filter((k) => k !== key) : [...current, key];
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
};
if (q.isError && !data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading || !data) {
return <LoadingState label={t("config.loading")} />;
}
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 (
<>
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
{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>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-[11px] text-emerald-500 mb-2">
{t("config.plexMediaOk")}
</p>
) : (
<p className="text-[11px] text-red-400 mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
</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>
);
}