feat: closed-set admin knobs are pickers, not free text
log_level, search_source, download_layout and the transcode hw/preset were typed by hand, and every consumer carried its own guess at what a typo meant. The value set is now a Literal in config.py: pydantic rejects a bad env value at boot (any letter case accepted), sysconfig refuses a bad override and derives the admin picker's options from the same Literal, and the consumers read the value as-is. The Configuration page renders a choice key as a segmented bar, or a dropdown past six options, with translated option names. Its tabs now follow the registry's order instead of a hand-kept copy that had already dropped the explore group. A single-choice FilterSelect now opens on the option in use: in the browser it could land on the first row instead.
This commit is contained in:
@@ -12,30 +12,17 @@ import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// 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. 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",
|
||||
"maintenance",
|
||||
"downloads",
|
||||
"audit",
|
||||
"plex",
|
||||
"tmdb",
|
||||
];
|
||||
// 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.
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
|
||||
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),
|
||||
);
|
||||
const groupKeys = Object.keys(data.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.
|
||||
@@ -330,6 +315,7 @@ function Field({
|
||||
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.
|
||||
@@ -356,7 +342,7 @@ function Field({
|
||||
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>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{t(`config.fields.${item.key}.hint`, "")}
|
||||
</p>
|
||||
@@ -366,10 +352,12 @@ function Field({
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
{isBool ? (
|
||||
<Switch
|
||||
label={t(`config.fields.${item.key}.label`, item.key)}
|
||||
label={label}
|
||||
checked={value === "true"}
|
||||
onChange={(v) => onChange(v ? "true" : "false")}
|
||||
/>
|
||||
) : item.type === "choice" ? (
|
||||
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
|
||||
) : (
|
||||
<input
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
@@ -379,7 +367,7 @@ function Field({
|
||||
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)}
|
||||
aria-label={label}
|
||||
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
@@ -400,3 +388,47 @@ function Field({
|
||||
</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.
|
||||
const MAX_SEGMENTS = 6;
|
||||
|
||||
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 ?? "");
|
||||
if (options.length <= MAX_SEGMENTS) {
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user