import { useId, 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 "../../components/ui/Tooltip"; import InlineNotice from "../../components/ui/InlineNotice"; import { useMe } from "../../lib/useMe"; import Tabs from "../../components/ui/Tabs"; import { useTabPanel } from "../../components/ui/tabPanel"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { Switch } from "../../components/ui/form"; import SegmentedToggle from "../../components/ui/SegmentedToggle"; import FilterSelect from "../../components/ui/FilterSelect"; import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar"; import { LS, useTabPersistedState } from "../../lib/storage"; import { CONFIG_TAB_KEY, configTabParams } from "../../lib/configTab"; import { useUrlMirror } from "../../lib/useUrlMirror"; import { notifyActionError } from "../../lib/actionErrors"; import { Button } from "../../components/ui/Button"; import { CheckRow } from "../../components/ui/NativeControls"; import { Input } from "../../components/ui/Input"; import { SURFACE } from "../../components/ui/surfaces"; // 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; nothing hits the server until Save. The tabs follow the // registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of // needing a second, hand-kept order list here. /** The admin's draft, plus the secrets marked to reset-to-default on the next Save (that can't be * expressed by clearing the field: an empty secret field means "leave unchanged"). Re-seeded from * `baseline` DURING render when `seedKey` changes, never on every new baseline: an unchanged refetch * produces a new object, and re-seeding on that is exactly the clobbering of an in-progress draft * that CB2 was about. (It was an effect: the first frame after a load painted the empty draft.) */ function useSeededDraft(baseline: Record, seedKey: string) { const [draft, setDraft] = useState>({}); const [secretReset, setSecretReset] = useState>({}); const [seededFor, setSeededFor] = useState(null); if (seededFor !== seedKey) { setSeededFor(seedKey); setDraft(baseline); setSecretReset({}); } return { draft, setDraft, secretReset, setSecretReset }; } /** The dirty keys' writes, one after another. Apart from ConfigPanel's save() so the loop and its * branches sit outside the try (the React Compiler cannot lower either inside one yet, and skipped * the whole panel). */ async function writeConfigItems( dirty: ConfigItem[], draft: Record, secretReset: Record, ) { for (const it of dirty) { const key = it.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 === "") { // 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); } } } export default function ConfigPanel() { const { t } = useTranslation(); const panel = useTabPanel(); const qc = useQueryClient(); const me = useMe(); const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig }); const data = q.data; const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]); // 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 = {}; for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? ""); return m; }, [items]); const [saveState, setSaveState] = useState("idle"); // Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit // refetch does NOT (see the seed 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] = useTabPersistedState(LS.configTab, "", CONFIG_TAB_KEY); // 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]); const { draft, setDraft, secretReset, setSecretReset } = useSeededDraft( baseline, `${reseedToken}:${dataVersion}`, ); const isDirty = (it: ConfigItem): boolean => { if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key]; return (draft[it.key] ?? "") !== (baseline[it.key] ?? ""); }; const dirtyItems = items.filter(isDirty); const dirty = dirtyItems.length > 0; async function save() { setSaveState("saving"); try { await writeConfigItems(dirtyItems, draft, secretReset); await qc.invalidateQueries({ queryKey: qk.adminConfig() }); // The email-not-configured notice reads me.email_configured, which is otherwise only // refetched on its own 60s interval — without this it keeps warning about fields the admin // has just filled in, which reads as the save not having taken. await qc.invalidateQueries({ queryKey: qk.me() }); setReseedToken((n) => n + 1); // re-seed the draft from the saved server state setSaveState("saved"); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); } catch (e) { setSaveState("error"); notifyActionError(e, 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: (e) => notifyActionError(e, 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(null); const testPlex = useMutation({ mutationFn: () => api.testPlex(), onSuccess: (r) => { setPlexResult(r); notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) }); }, onError: (e) => { setPlexResult(null); notifyActionError(e, t("config.plexTestFailed")); }, }); // Bug reports' "Test connection": the repository answers, takes issues, is private. const testIssues = useMutation({ mutationFn: () => api.testIssues(), onSuccess: (r) => notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }), onError: (e) => notifyActionError(e, t("config.issuesTestFailed")), }); 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(",") })); }; // The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133% // zoom, so they are a side list under four headings, as Settings has). const groupKeys = data?.sections.flatMap((s) => s.groups) ?? []; // 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]; // The address names the group on screen (always: see SettingsPanel); until the registry is in, the // one it was opened on. useUrlMirror(configTabParams((data ? activeGroup : tab) || null)); if (q.isError && !data) { return ( q.refetch()}> {t("common.loadError")} ); } if (q.isLoading || !data) { return ; } const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean)); const groupTabs = data.sections.flatMap((s) => s.groups.map((g) => ({ id: g, group: t(`config.sections.${s.id}`, s.id), label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`, testId: `config-tab-${g}`, })), ); return ( <>

{t("config.intro")}

{/* The groups as a side list under their headings, the fields beside it: Settings' own layout, one pattern for both settings pages. */}
{activeGroup && (
{(data.groups[activeGroup] ?? []).map((item) => ( setDraft((d) => ({ ...d, [item.key]: v }))} onResetField={() => { if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); else setDraft((d) => ({ ...d, [item.key]: "" })); }} /> ))}
{activeGroup === "email" && (
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a half-filled config costs verification links, resets and access-request notices with no error anywhere — say so next to the fields that fix it. */} {!me.email_configured && {t("config.emailOff")}} {dirty &&

{t("config.testDirty")}

}
)} {activeGroup === "issues" && (
{dirty &&

{t("config.testDirty")}

}
)} {activeGroup === "plex" && (
{dirty &&

{t("config.testDirty")}

} {plexResult && (

{t("config.plexConnected", { name: plexResult.server_name, version: plexResult.version ?? "", })}

{plexResult.media_check?.checked && (plexResult.media_check.ok ? (

{t("config.plexMediaOk")}

) : (

{t("config.plexMediaMissing", { path: plexResult.media_check.local_path ?? "", })}

))}

{t("config.plexPickLibraries")}

{plexResult.sections.map((s) => ( togglePlexLib(s.key)} className="text-sm" > {s.title} {s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")} {s.count != null ? ` · ${s.count}` : ""} ))}

{t("config.plexPickHint")}

)}
)}
)}
); } 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 label = t(`config.fields.${item.key}.label`, item.key); 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; const switchId = useId(); const Text = isBool ? "label" : "div"; return ( // Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the // label and hint a sliver of a phone's width.
{/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a label may only hold phrasing content, hence the block spans. */} {label} {t(`config.fields.${item.key}.hint`, "")} {status}
{isBool ? ( onChange(v ? "true" : "false")} /> ) : item.type === "choice" ? ( ) : ( 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={label} /> )} {showReset && !secretDisabled && ( )}
); } // A closed-set key picks from its options instead of taking free text: a segmented bar for a short // list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the // default on Save) shows the default as the active option, as a text field shows it as placeholder. // // "Short" is also about the words, not just their number: a bar cannot shrink or wrap, and five level // names (TRACE … WARNING, ~300 px) are wider than a phone leaves beside the navigation rail (~250 px). // Past MAX_SEGMENT_CHARS of labels, in the language shown, it is a dropdown too. const MAX_SEGMENTS = 6; const MAX_SEGMENT_CHARS = 22; function ChoiceField({ item, label, value, onChange, }: { item: ConfigItem; label: string; value: string; onChange: (v: string) => void; }) { const { t } = useTranslation(); const options = item.choices.map((c) => ({ value: c, label: t(`config.choices.${item.key}.${c}`, c), })); const current = value || String(item.default ?? ""); const labelChars = options.reduce((sum, o) => sum + o.label.length, 0); if (options.length <= MAX_SEGMENTS && labelChars <= MAX_SEGMENT_CHARS) { return ( ); } return ( next && onChange(next)} /> ); }