- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
518 lines
22 KiB
TypeScript
518 lines
22 KiB
TypeScript
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<string, string>, seedKey: string) {
|
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
|
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
|
const [seededFor, setSeededFor] = useState<string | null>(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<string, string>,
|
|
secretReset: Record<string, boolean>,
|
|
) {
|
|
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<string, string> = {};
|
|
for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? "");
|
|
return m;
|
|
}, [items]);
|
|
|
|
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 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<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: (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 (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
);
|
|
}
|
|
if (q.isLoading || !data) {
|
|
return <LoadingState label={t("config.loading")} />;
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div className="px-4 pb-24 pt-3 max-w-4xl w-full mx-auto">
|
|
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
|
|
{/* The groups as a side list under their headings, the fields beside it: Settings' own
|
|
layout, one pattern for both settings pages. */}
|
|
<div className={`${SURFACE.panel} mb-4 flex flex-col overflow-hidden sm:flex-row`}>
|
|
<Tabs
|
|
idBase={panel.idBase}
|
|
orientation="vertical"
|
|
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-52 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
|
|
tabs={groupTabs}
|
|
active={activeGroup ?? ""}
|
|
onChange={setTab}
|
|
/>
|
|
{activeGroup && (
|
|
<div {...panel.panelProps(activeGroup ?? "")} className="min-w-0 flex-1 p-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 space-y-3">
|
|
{/* 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 && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
|
<Tooltip hint={t("config.testEmailHint")}>
|
|
<Button
|
|
variant="glass"
|
|
icon={<Send />}
|
|
loading={testEmail.isPending}
|
|
aria-disabled={dirty}
|
|
onClick={() => testEmail.mutate()}
|
|
>
|
|
{t("config.testEmail")}
|
|
</Button>
|
|
</Tooltip>
|
|
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{activeGroup === "issues" && (
|
|
<div className="mt-3 pt-3 border-t border-border/60">
|
|
<Tooltip hint={t("config.issuesTestHint")}>
|
|
<Button
|
|
variant="glass"
|
|
icon={<Plug />}
|
|
loading={testIssues.isPending}
|
|
aria-disabled={dirty}
|
|
onClick={() => testIssues.mutate()}
|
|
>
|
|
{t("config.issuesTest")}
|
|
</Button>
|
|
</Tooltip>
|
|
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{activeGroup === "plex" && (
|
|
<div className="mt-3 pt-3 border-t border-border/60">
|
|
<Tooltip hint={t("config.plexTestHint")}>
|
|
<Button
|
|
variant="glass"
|
|
icon={<Plug />}
|
|
loading={testPlex.isPending}
|
|
aria-disabled={dirty}
|
|
onClick={() => testPlex.mutate()}
|
|
>
|
|
{t("config.plexTest")}
|
|
</Button>
|
|
</Tooltip>
|
|
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</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-2xs text-success mb-2">{t("config.plexMediaOk")}</p>
|
|
) : (
|
|
<p className="text-2xs text-danger mb-2">
|
|
{t("config.plexMediaMissing", {
|
|
path: plexResult.media_check.local_path ?? "",
|
|
})}
|
|
</p>
|
|
))}
|
|
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
|
|
<div className="flex flex-col gap-1.5">
|
|
{plexResult.sections.map((s) => (
|
|
<CheckRow
|
|
key={s.key}
|
|
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
|
|
onChange={() => togglePlexLib(s.key)}
|
|
className="text-sm"
|
|
>
|
|
<span>{s.title}</span>
|
|
<span className="text-2xs text-muted">
|
|
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
|
|
{s.count != null ? ` · ${s.count}` : ""}
|
|
</span>
|
|
</CheckRow>
|
|
))}
|
|
</div>
|
|
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
|
|
</div>
|
|
)}
|
|
</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 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.
|
|
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
|
{/* 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. */}
|
|
<Text
|
|
{...(isBool && { htmlFor: switchId })}
|
|
className={`min-w-0 flex-1 ${isBool ? "cursor-pointer select-none" : ""}`}
|
|
>
|
|
<span className="block text-sm font-medium">{label}</span>
|
|
<span className="block text-xs text-muted leading-relaxed mt-0.5">
|
|
{t(`config.fields.${item.key}.hint`, "")}
|
|
</span>
|
|
<span className="block text-2xs text-muted mt-1">{status}</span>
|
|
</Text>
|
|
|
|
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
|
|
{isBool ? (
|
|
<Switch
|
|
id={switchId}
|
|
label={label}
|
|
checked={value === "true"}
|
|
onChange={(v) => onChange(v ? "true" : "false")}
|
|
/>
|
|
) : item.type === "choice" ? (
|
|
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
|
|
) : (
|
|
<Input
|
|
className="w-52"
|
|
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={label}
|
|
/>
|
|
)}
|
|
{showReset && !secretDisabled && (
|
|
<Tooltip hint={t("config.resetHint")}>
|
|
<Button
|
|
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
|
|
icon={<RotateCcw className="size-3.5!" />}
|
|
onClick={onResetField}
|
|
className="text-xs gap-1"
|
|
>
|
|
{t("config.reset")}
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<SegmentedToggle
|
|
options={options}
|
|
value={current}
|
|
onChange={onChange}
|
|
ariaLabel={label}
|
|
size="md"
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<FilterSelect
|
|
label={label}
|
|
hideLabel
|
|
options={options}
|
|
selected={[current]}
|
|
onChange={([next]) => next && onChange(next)}
|
|
/>
|
|
);
|
|
}
|