2026-05-22 20:26:11 -04:00
|
|
|
import { Slider } from "@/components/ui/slider";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
2026-07-30 10:52:41 -04:00
|
|
|
import { controlKindFor, optionsFor, type SettingDisplay } from "@/lib/settingsDisplay";
|
2026-05-22 20:26:11 -04:00
|
|
|
|
|
|
|
|
const EMPTY_SELECT_VALUE = "__empty__";
|
|
|
|
|
|
|
|
|
|
interface RegistrySettingControlProps {
|
2026-07-30 10:52:41 -04:00
|
|
|
/**
|
|
|
|
|
* The generated definition this control edits, as
|
|
|
|
|
* {@link SettingDisplay} resolves it from SETTING_DEFINITIONS.
|
|
|
|
|
*/
|
|
|
|
|
definition: SettingDisplay;
|
2026-05-22 20:26:11 -04:00
|
|
|
value: string;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 10:52:41 -04:00
|
|
|
/**
|
|
|
|
|
* Renders one admin-facing setting control from the generated contract.
|
|
|
|
|
*
|
|
|
|
|
* The control shape, bounds, and option list all come from the manifest, so a
|
|
|
|
|
* definition the server knows and this build does not is impossible to edit
|
|
|
|
|
* with the wrong widget — the hand-written registry this used to read could
|
|
|
|
|
* (and did) disagree with the server about a setting's type and range.
|
|
|
|
|
*
|
|
|
|
|
* Values are strings here on purpose: the admin surface edits the string form
|
|
|
|
|
* and re-types on save through the contract (see
|
|
|
|
|
* hooks/queries/admin/users.ts), which keeps this component free of the typed
|
|
|
|
|
* JSON round trip.
|
|
|
|
|
*/
|
2026-05-22 20:26:11 -04:00
|
|
|
export function RegistrySettingControl({
|
|
|
|
|
definition,
|
|
|
|
|
value,
|
|
|
|
|
disabled = false,
|
|
|
|
|
onChange,
|
|
|
|
|
}: RegistrySettingControlProps) {
|
2026-07-30 10:52:41 -04:00
|
|
|
const control = controlKindFor(definition);
|
|
|
|
|
|
|
|
|
|
if (control === "switch") {
|
2026-05-22 20:26:11 -04:00
|
|
|
return (
|
|
|
|
|
<Switch
|
|
|
|
|
checked={value === "true"}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onCheckedChange={(checked) => onChange(checked ? "true" : "false")}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 10:52:41 -04:00
|
|
|
if (control === "slider" || control === "stepper") {
|
|
|
|
|
const fallback = Number(definition.defaultValue ?? 0);
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
const numericValue = Number.isFinite(parsed) && value !== "" ? parsed : fallback;
|
2026-05-22 20:26:11 -04:00
|
|
|
return (
|
|
|
|
|
<div className="flex w-full max-w-[260px] items-center gap-3">
|
|
|
|
|
<Slider
|
|
|
|
|
value={[numericValue]}
|
2026-07-30 10:52:41 -04:00
|
|
|
min={definition.minimum}
|
|
|
|
|
max={definition.maximum}
|
2026-05-22 20:26:11 -04:00
|
|
|
step={definition.step}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onValueCommit={(values) => onChange(String(values[0] ?? numericValue))}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-muted-foreground min-w-16 text-right text-xs font-medium">
|
|
|
|
|
{numericValue}
|
|
|
|
|
{definition.unit ? ` ${definition.unit}` : ""}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value === "" ? EMPTY_SELECT_VALUE : value}
|
|
|
|
|
onValueChange={(nextValue) => onChange(nextValue === EMPTY_SELECT_VALUE ? "" : nextValue)}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="w-full min-w-[180px] sm:w-[220px]">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-07-30 10:52:41 -04:00
|
|
|
{optionsFor(definition).map((option) => (
|
2026-05-22 20:26:11 -04:00
|
|
|
<SelectItem
|
|
|
|
|
key={option.value || EMPTY_SELECT_VALUE}
|
|
|
|
|
value={option.value === "" ? EMPTY_SELECT_VALUE : option.value}
|
|
|
|
|
>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
}
|