Full UI configuration (#22151)

* use react-jsonschema-form for UI config

* don't use properties wrapper when generating config i18n json

* configure for full i18n support

* section fields

* add descriptions to all fields for i18n

* motion i18n

* fix nullable fields

* sanitize internal fields

* add switches widgets and use friendly names

* fix nullable schema entries

* ensure update_topic is added to api calls

this needs further backend implementation to work correctly

* add global sections, camera config overrides, and reset button

* i18n

* add reset logic to global config view

* tweaks

* fix sections and live validation

* fix validation for schema objects that can be null

* generic and custom per-field validation

* improve generic error validation messages

* remove show advanced fields switch

* tweaks

* use shadcn theme

* fix array field template

* i18n tweaks

* remove collapsible around root section

* deep merge schema for advanced fields

* add array field item template and fix ffmpeg section

* add missing i18n keys

* tweaks

* comment out api call for testing

* add config groups as a separate i18n namespace

* add descriptions to all pydantic fields

* make titles more concise

* new titles as i18n

* update i18n config generation script to use json schema

* tweaks

* tweaks

* rebase

* clean up

* form tweaks

* add wildcards and fix object filter fields

* add field template for additionalproperties schema objects

* improve typing

* add section description from schema and clarify global vs camera level descriptions

* separate and consolidate global and camera i18n namespaces

* clean up now obsolete namespaces

* tweaks

* refactor sections and overrides

* add ability to render components before and after fields

* fix titles

* chore(sections): remove legacy single-section components replaced by template

* refactor configs to use individual files with a template

* fix review description

* apply hidden fields after ui schema

* move util

* remove unused i18n

* clean up error messages

* fix fast refresh

* add custom validation and use it for ffmpeg input roles

* update nav tree

* remove unused

* re-add override and modified indicators

* mark pending changes and add confirmation dialog for resets

* fix red unsaved dot

* tweaks

* add docs links, readonly keys, and restart required per field

* add special case and comments for global motion section

* add section form special cases

* combine review sections

* tweaks

* add audio labels endpoint

* add audio label switches and input to filter list

* fix type

* remove key from config when resetting to default/global

* don't show description for new key/val fields

* tweaks

* spacing tweaks

* add activity indicator and scrollbar tweaks

* add docs to filter fields

* wording changes

* fix global ffmpeg section

* add review classification zones to review form

* add backend endpoint and frontend widget for ffmpeg presets and manual args

* improve wording

* hide descriptions for additional properties arrays

* add warning log about incorrectly nested model config

* spacing and language tweaks

* fix i18n keys

* networking section docs and description

* small wording tweaks

* add layout grid field

* refactor with shared utilities

* field order

* add individual detectors to schema

add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals

* clean up detectors section and i18n

* don't save model config back to yaml when saving detectors

* add full detectors config to api model dump

works around the way we use detector plugins so we can have the full detector config for the frontend

* add restart button to toast when restart is required

* add ui option to remove inner cards

* fix buttons

* section tweaks

* don't zoom into text on mobile

* make buttons sticky at bottom of sections

* small tweaks

* highlight label of changed fields

* add null to enum list when unwrapping

* refactor to shared utils and add save all button

* add undo all button

* add RJSF to dictionary

* consolidate utils

* preserve form data when changing cameras

* add mono fonts

* add popover to show what fields will be saved

* fix mobile menu not re-rendering with unsaved dots

* tweaks

* fix logger and env vars config section saving

use escaped periods in keys to retain them in the config file (eg "frigate.embeddings")

* add timezone widget

* role map field with validation

* fix validation for model section

* add another hidden field

* add footer message for required restart

* use rjsf for notifications view

* fix config saving

* add replace rules field

* default column layout and add field sizing

* clean up field template

* refactor profile settings to match rjsf forms

* tweaks

* refactor frigate+ view and make tweaks to sections

* show frigate+ model info in detection model settings when using a frigate+ model

* update restartRequired for all fields

* fix restart fields

* tweaks and add ability enable disabled cameras

more backend changes required

* require restart when enabling camera that is disabled in config

* disable save when form is invalid

* refactor ffmpeg section for readability

* change label

* clean up camera inputs fields

* misc tweaks to ffmpeg section

- add raw paths endpoint to ensure credentials get saved
- restart required tooltip

* maintenance settings tweaks

* don't mutate with lodash

* fix description re-rendering for nullable object fields

* hide reindex field

* update rjsf

* add frigate+ description to settings pane

* disable save all when any section is invalid

* show translated field name in validation error pane

* clean up

* remove unused

* fix genai merge

* fix genai
This commit is contained in:
Josh Hawkins
2026-02-27 08:55:36 -07:00
committed by GitHub
parent eeefbf2bb5
commit e7250f24cb
206 changed files with 22200 additions and 4435 deletions
@@ -0,0 +1,36 @@
// Widget that displays an array as a concatenated text string
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { useCallback } from "react";
export function ArrayAsTextWidget(props: WidgetProps) {
const { value, onChange, disabled, readonly, placeholder } = props;
// Convert array or string to text
let textValue = "";
if (typeof value === "string" && value.length > 0) {
textValue = value;
} else if (Array.isArray(value) && value.length > 0) {
textValue = value.join(" ");
}
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
// Convert space-separated string back to array
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
onChange(newArray);
},
[onChange],
);
return (
<Input
value={textValue}
onChange={handleChange}
disabled={disabled}
readOnly={readonly}
placeholder={placeholder}
/>
);
}
@@ -0,0 +1,101 @@
// Audio Label Switches Widget - For selecting audio labels via switches
import type { WidgetProps } from "@rjsf/utils";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import { JsonObject } from "@/types/configForm";
function getEnabledAudioLabels(context: FormContext): string[] {
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
if (context) {
// context.cameraValue and context.globalValue should be the entire audio section
if (
context.cameraValue &&
typeof context.cameraValue === "object" &&
!Array.isArray(context.cameraValue)
) {
const listenValue = (context.cameraValue as JsonObject).listen;
if (Array.isArray(listenValue)) {
cameraLabels = listenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
if (
context.globalValue &&
typeof context.globalValue === "object" &&
!Array.isArray(context.globalValue)
) {
const globalListenValue = (context.globalValue as JsonObject).listen;
if (Array.isArray(globalListenValue)) {
globalLabels = globalListenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
}
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
return [...sourceLabels].sort();
}
function getAudioLabelDisplayName(label: string): string {
return getTranslatedLabel(label, "audio");
}
export function AudioLabelSwitchesWidget(props: WidgetProps) {
const { data: audioLabels } = useSWR<Record<string, string>>("/audio_labels");
const allLabels = useMemo(() => {
if (!audioLabels) {
return [];
}
const labelSet = new Set<string>();
Object.values(audioLabels).forEach((label) => {
if (typeof label !== "string") {
return;
}
const normalized = label.trim();
if (normalized) {
labelSet.add(normalized);
}
});
return [...labelSet].sort();
}, [audioLabels]);
const getEntities = useCallback(
(context: FormContext) => {
const enabledLabels = getEnabledAudioLabels(context);
if (allLabels.length === 0) {
return enabledLabels;
}
const combinedLabels = new Set([...allLabels, ...enabledLabels]);
return [...combinedLabels].sort();
},
[allLabels],
);
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities,
getDisplayLabel: getAudioLabelDisplayName,
i18nKey: "audioLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
enableSearch: true,
}}
/>
);
}
@@ -0,0 +1,202 @@
import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr";
import { useMemo, useState, type FocusEvent } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
type RawPathsResponse = {
cameras?: Record<
string,
{
ffmpeg?: {
inputs?: Array<{
path?: string;
}>;
};
}
>;
};
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
const getInputIndexFromWidgetId = (id: string): number | undefined => {
const match = id.match(/_inputs_(\d+)_path$/);
if (!match) {
return undefined;
}
const index = Number(match[1]);
return Number.isNaN(index) ? undefined : index;
};
const isMaskedPath = (value: string): boolean =>
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
const hasCredentials = (value: string): boolean => {
if (!value) {
return false;
}
if (isMaskedPath(value)) {
return true;
}
try {
const parsed = new URL(value);
if (parsed.username || parsed.password) {
return true;
}
return (
parsed.searchParams.has("user") && parsed.searchParams.has("password")
);
} catch {
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
}
};
const maskCredentials = (value: string): string => {
if (!value) {
return value;
}
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
return maskedAuth
.replace(/([?&]user=)[^&]*/gi, "$1*")
.replace(/([?&]password=)[^&]*/gi, "$1*");
};
export function CameraPathWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const { t } = useTranslation(["common", "views/settings"]);
const [showCredentials, setShowCredentials] = useState(false);
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const isCameraLevel = formContext?.level === "camera";
const cameraName = formContext?.cameraName;
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
const shouldFetchRawPaths =
isCameraLevel && !!cameraName && inputIndex !== undefined;
const { data: rawPaths } = useSWR<RawPathsResponse>(
shouldFetchRawPaths ? "config/raw_paths" : null,
);
const rawPath = useMemo(() => {
if (!cameraName || inputIndex === undefined) {
return undefined;
}
const path =
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
return typeof path === "string" ? path : undefined;
}, [cameraName, inputIndex, rawPaths]);
const rawValue = typeof value === "string" ? value : "";
const resolvedValue =
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
const canReveal =
hasCredentials(resolvedValue) && !isMaskedPath(resolvedValue);
const canToggle = canReveal || isMaskedPath(rawValue);
const isMaskedView = canToggle && !showCredentials;
const displayValue = isMaskedView
? maskCredentials(resolvedValue)
: resolvedValue;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
const fieldClassName = getSizedFieldClassName(options, "xs");
const uriLabel = t("cameraWizard.step3.url", {
ns: "views/settings",
defaultValue: schema.title,
});
const toggleLabel = showCredentials
? t("label.hide", { ns: "common", item: uriLabel })
: t("label.show", { ns: "common", item: uriLabel });
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
if (isMaskedView && canReveal) {
setShowCredentials(true);
onFocus(id, resolvedValue);
return;
}
onFocus(id, event.target.value);
};
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (canToggle) {
setShowCredentials(false);
}
onBlur(id, event.target.value);
};
return (
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
className={cn("text-md", canToggle ? "pr-10" : undefined)}
type="text"
value={displayValue}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={handleBlur}
onFocus={handleFocus}
aria-label={schema.title}
/>
{canToggle ? (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowCredentials((previous) => !previous)}
disabled={disabled || (!canReveal && !showCredentials)}
aria-label={toggleLabel}
title={toggleLabel}
>
{showCredentials ? (
<LuEyeOff className="h-4 w-4" />
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,17 @@
// Checkbox Widget - maps to shadcn/ui Checkbox
import type { WidgetProps } from "@rjsf/utils";
import { Checkbox } from "@/components/ui/checkbox";
export function CheckboxWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Checkbox
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Checkbox"}
/>
);
}
@@ -0,0 +1,53 @@
// Color Widget - For RGB color objects
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useMemo, useCallback } from "react";
interface RGBColor {
red: number;
green: number;
blue: number;
}
export function ColorWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
// Convert object to hex for color picker
const hexValue = useMemo(() => {
if (!value || typeof value !== "object") {
return "#ffffff";
}
const { red = 255, green = 255, blue = 255 } = value as RGBColor;
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
}, [value]);
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const hex = e.target.value;
const red = parseInt(hex.slice(1, 3), 16);
const green = parseInt(hex.slice(3, 5), 16);
const blue = parseInt(hex.slice(5, 7), 16);
onChange({ red, green, blue });
},
[onChange],
);
return (
<div className="flex items-center gap-3">
<Input
id={id}
type="color"
value={hexValue}
disabled={disabled || readonly}
onChange={handleColorChange}
className="h-10 w-14 cursor-pointer p-1"
/>
<div className="flex gap-2 text-sm text-muted-foreground">
<Label>R: {(value as RGBColor)?.red ?? 255}</Label>
<Label>G: {(value as RGBColor)?.green ?? 255}</Label>
<Label>B: {(value as RGBColor)?.blue ?? 255}</Label>
</div>
</div>
);
}
@@ -0,0 +1,344 @@
import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { ConfigFormContext } from "@/types/configForm";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type FfmpegPresetResponse = {
hwaccel_args: string[];
input_args: string[];
output_args: {
record: string[];
detect: string[];
};
};
type FfmpegArgsMode = "preset" | "manual" | "inherit";
type PresetField =
| "hwaccel_args"
| "input_args"
| "output_args.record"
| "output_args.detect";
const getPresetOptions = (
data: FfmpegPresetResponse | undefined,
field: PresetField | undefined,
): string[] => {
if (!data || !field) {
return [];
}
if (field === "hwaccel_args") {
return data.hwaccel_args;
}
if (field === "input_args") {
return data.input_args;
}
if (field.startsWith("output_args.")) {
const key = field.split(".")[1] as "record" | "detect";
return data.output_args?.[key] ?? [];
}
return [];
};
const resolveMode = (
value: unknown,
presets: string[],
defaultMode: FfmpegArgsMode,
allowInherit: boolean,
): FfmpegArgsMode => {
if (allowInherit && (value === null || value === undefined)) {
return "inherit";
}
if (allowInherit && Array.isArray(value) && value.length === 0) {
return "inherit";
}
if (Array.isArray(value)) {
return "manual";
}
if (typeof value === "string") {
if (presets.length === 0) {
return defaultMode;
}
return presets.includes(value) ? "preset" : "manual";
}
return defaultMode;
};
const normalizeManualText = (value: unknown): string => {
if (Array.isArray(value)) {
return value.join(" ");
}
if (typeof value === "string") {
return value;
}
return "";
};
export function FfmpegArgsWidget(props: WidgetProps) {
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
"views/settings",
]);
const {
value,
onChange,
disabled,
readonly,
options,
placeholder,
schema,
id,
} = props;
const presetField = options?.ffmpegPresetField as PresetField | undefined;
const allowInherit = options?.allowInherit === true;
const hideDescription = options?.hideDescription === true;
const useSplitLayout = options?.splitLayout !== false;
const { data } = useSWR<FfmpegPresetResponse>("ffmpeg/presets");
const presetOptions = useMemo(
() => getPresetOptions(data, presetField),
[data, presetField],
);
const canUsePresets = presetOptions.length > 0;
const defaultMode: FfmpegArgsMode = canUsePresets ? "preset" : "manual";
const detectedMode = useMemo(
() => resolveMode(value, presetOptions, defaultMode, allowInherit),
[value, presetOptions, defaultMode, allowInherit],
);
const [mode, setMode] = useState<FfmpegArgsMode>(detectedMode);
useEffect(() => {
if (!canUsePresets && detectedMode === "preset") {
setMode("manual");
return;
}
setMode(detectedMode);
}, [canUsePresets, detectedMode]);
const handleModeChange = useCallback(
(nextMode: FfmpegArgsMode) => {
setMode(nextMode);
if (nextMode === "inherit") {
onChange(undefined);
return;
}
if (nextMode === "preset") {
const currentValue = typeof value === "string" ? value : undefined;
const presetValue =
currentValue && presetOptions.includes(currentValue)
? currentValue
: presetOptions[0];
if (presetValue) {
onChange(presetValue);
}
return;
}
if (mode === "preset") {
onChange("");
return;
}
const manualText = normalizeManualText(value);
onChange(manualText);
},
[mode, onChange, presetOptions, value],
);
const handlePresetChange = useCallback(
(preset: string) => {
onChange(preset);
},
[onChange],
);
const handleManualChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
onChange(newText);
},
[onChange],
);
const manualValue = normalizeManualText(value);
const presetValue =
typeof value === "string" && presetOptions.includes(value) ? value : "";
const fallbackDescriptionKey = useMemo(() => {
if (!presetField) {
return undefined;
}
const isInputScoped = id.includes("_inputs_");
const prefix = isInputScoped ? "ffmpeg.inputs" : "ffmpeg";
if (presetField === "hwaccel_args") {
return `${prefix}.hwaccel_args.description`;
}
if (presetField === "input_args") {
return `${prefix}.input_args.description`;
}
if (presetField === "output_args.record") {
return isInputScoped
? "ffmpeg.inputs.output_args.record.description"
: "ffmpeg.output_args.record.description";
}
if (presetField === "output_args.detect") {
return isInputScoped
? "ffmpeg.inputs.output_args.detect.description"
: "ffmpeg.output_args.detect.description";
}
return undefined;
}, [id, presetField]);
const translatedDescription =
fallbackDescriptionKey &&
effectiveNamespace &&
i18n.exists(fallbackDescriptionKey, { ns: effectiveNamespace })
? t(fallbackDescriptionKey, { ns: effectiveNamespace })
: "";
const fieldDescription =
typeof schema.description === "string" && schema.description.length > 0
? schema.description
: translatedDescription;
return (
<div className="space-y-2">
<RadioGroup
value={mode}
onValueChange={(next) => handleModeChange(next as FfmpegArgsMode)}
className="gap-3"
>
{allowInherit ? (
<div className="flex items-center space-x-2">
<RadioGroupItem
value="inherit"
id={`${id}-inherit`}
disabled={disabled || readonly}
className={
mode === "inherit"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-inherit`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.inherit", { ns: "views/settings" })}
</label>
</div>
) : null}
<div className="flex items-center space-x-2">
<RadioGroupItem
value="preset"
id={`${id}-preset`}
disabled={disabled || readonly || !canUsePresets}
className={
mode === "preset"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-preset`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.preset", { ns: "views/settings" })}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id={`${id}-manual`}
disabled={disabled || readonly}
className={
mode === "manual"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-manual`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.manual", { ns: "views/settings" })}
</label>
</div>
</RadioGroup>
{mode === "inherit" ? null : mode === "preset" && canUsePresets ? (
<Select
value={presetValue}
onValueChange={handlePresetChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className="w-full md:max-w-md">
<SelectValue
placeholder={
placeholder ||
schema.title ||
t("configForm.ffmpegArgs.selectPreset", {
ns: "views/settings",
})
}
/>
</SelectTrigger>
<SelectContent>
{presetOptions.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={id}
value={manualValue}
onChange={handleManualChange}
disabled={disabled || readonly}
placeholder={
placeholder ||
t("configForm.ffmpegArgs.manualPlaceholder", {
ns: "views/settings",
})
}
/>
)}
{!hideDescription && !useSplitLayout && fieldDescription ? (
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
) : null}
</div>
);
}
@@ -0,0 +1,67 @@
import type { WidgetProps } from "@rjsf/utils";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
const INPUT_ROLES = ["detect", "record", "audio"] as const;
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim()) {
return [value.trim()];
}
return [];
}
export function InputRolesWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
const { t } = useTranslation(["views/settings"]);
const selectedRoles = useMemo(() => normalizeValue(value), [value]);
const toggleRole = (role: string, enabled: boolean) => {
if (enabled) {
if (!selectedRoles.includes(role)) {
onChange([...selectedRoles, role]);
}
return;
}
onChange(selectedRoles.filter((item) => item !== role));
};
return (
<div className="rounded-lg border border-secondary-highlight bg-background_alt p-2 pr-0 md:max-w-md">
<div className="grid gap-2">
{INPUT_ROLES.map((role) => {
const checked = selectedRoles.includes(role);
const label = t(`configForm.inputRoles.options.${role}`, {
ns: "views/settings",
defaultValue: role,
});
return (
<div
key={role}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
<Switch
id={`${id}-${role}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(enabled) => toggleRole(role, !!enabled)}
/>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,44 @@
// Number Widget - Input with number type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function NumberWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
schema,
options,
} = props;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange(undefined);
} else {
const num =
schema.type === "integer" ? parseInt(val, 10) : parseFloat(val);
onChange(isNaN(num) ? undefined : num);
}
};
return (
<Input
id={id}
type="number"
value={value ?? ""}
disabled={disabled || readonly}
min={schema.minimum}
max={schema.maximum}
step={(options.step as number) || (schema.type === "integer" ? 1 : 0.1)}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,101 @@
// Object Label Switches Widget - For selecting objects via switches
import { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject } from "@/types/configForm";
// Collect labelmap values (human-readable labels) from a labelmap object.
function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) {
if (!labelmap || typeof labelmap !== "object") {
return;
}
Object.values(labelmap as JsonObject).forEach((value) => {
if (typeof value === "string" && value.trim().length > 0) {
labels.add(value);
}
});
}
// Read labelmap labels from the global model and detector models.
function getLabelmapLabels(context: FormContext): string[] {
const labels = new Set<string>();
const fullConfig = context.fullConfig as FrigateConfig | undefined;
if (fullConfig?.model) {
collectLabelmapLabels(fullConfig.model.labelmap, labels);
}
if (fullConfig?.detectors) {
// detectors is a map of detector configs; each may include a model labelmap.
Object.values(fullConfig.detectors).forEach((detector) => {
if (detector?.model?.labelmap) {
collectLabelmapLabels(detector.model.labelmap, labels);
}
});
}
return [...labels];
}
// Build the list of labels for switches (labelmap + configured track list).
function getObjectLabels(context: FormContext): string[] {
const labelmapLabels = getLabelmapLabels(context);
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
if (context) {
// context.cameraValue and context.globalValue should be the entire objects section
if (
context.cameraValue &&
typeof context.cameraValue === "object" &&
!Array.isArray(context.cameraValue)
) {
const trackValue = (context.cameraValue as JsonObject).track;
if (Array.isArray(trackValue)) {
cameraLabels = trackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
if (
context.globalValue &&
typeof context.globalValue === "object" &&
!Array.isArray(context.globalValue)
) {
const globalTrackValue = (context.globalValue as JsonObject).track;
if (Array.isArray(globalTrackValue)) {
globalLabels = globalTrackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
}
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
const combinedLabels = new Set<string>([...labelmapLabels, ...sourceLabels]);
return [...combinedLabels].sort();
}
function getObjectLabelDisplayName(label: string): string {
return getTranslatedLabel(label, "object");
}
export function ObjectLabelSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getObjectLabels,
getDisplayLabel: getObjectLabelDisplayName,
i18nKey: "objectLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);
}
@@ -0,0 +1,59 @@
// Password Widget - Input with password type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
export function PasswordWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const [showPassword, setShowPassword] = useState(false);
const fieldClassName = getSizedFieldClassName(options, "sm");
return (
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
className="w-full pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
>
{showPassword ? (
<LuEyeOff className="h-4 w-4" />
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,31 @@
// Range Widget - maps to shadcn/ui Slider
import type { WidgetProps } from "@rjsf/utils";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
export function RangeWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const min = schema.minimum ?? 0;
const max = schema.maximum ?? 100;
const step =
(options.step as number) || (schema.type === "integer" ? 1 : 0.1);
return (
<div className="flex items-center gap-4">
<Slider
id={id}
value={[value ?? min]}
min={min}
max={max}
step={step}
disabled={disabled || readonly}
onValueChange={(vals) => onChange(vals[0])}
className={cn("flex-1", disabled && "opacity-50")}
/>
<span className="w-12 text-right text-sm text-muted-foreground">
{value ?? min}
</span>
</div>
);
}
@@ -0,0 +1,51 @@
// Select Widget - maps to shadcn/ui Select
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getSizedFieldClassName } from "../utils";
export function SelectWidget(props: WidgetProps) {
const {
id,
options,
value,
disabled,
readonly,
onChange,
placeholder,
schema,
} = props;
const { enumOptions = [] } = options;
const fieldClassName = getSizedFieldClassName(options, "sm");
return (
<Select
value={value?.toString() ?? ""}
onValueChange={(val) => {
// Convert back to original type if needed
const enumVal = enumOptions.find(
(opt: { value: unknown }) => opt.value?.toString() === val,
);
onChange(enumVal ? enumVal.value : val);
}}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={placeholder || schema.title || "Select..."} />
</SelectTrigger>
<SelectContent>
{enumOptions.map((option: { value: unknown; label: string }) => (
<SelectItem key={String(option.value)} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,17 @@
// Switch Widget - maps to shadcn/ui Switch
import type { WidgetProps } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
export function SwitchWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Switch
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Toggle"}
/>
);
}
@@ -0,0 +1,231 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities
import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
type FormContext = Pick<
ConfigFormContext,
| "cameraValue"
| "globalValue"
| "fullCameraConfig"
| "fullConfig"
| "t"
| "level"
> & {
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
};
export type { FormContext };
export type SwitchesWidgetOptions = {
/** Function to extract available entities from context */
getEntities: (context: FormContext) => string[];
/** Function to get display label for an entity (e.g., translate, get friendly name) */
getDisplayLabel?: (entity: string, context?: FormContext) => string;
/** i18n key prefix (e.g., "objectLabels", "zoneNames") */
i18nKey: string;
/** Translation namespace (default: "views/settings") */
namespace?: string;
/** Optional class name for the list container */
listClassName?: string;
/** Enable search input to filter the list */
enableSearch?: boolean;
};
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim().length > 0) {
return [value.trim()];
}
return [];
}
/**
* Generic switches widget for selecting from any list of entities (objects, zones, etc.)
*
* @example
* // In uiSchema:
* "track": {
* "ui:widget": "switches",
* "ui:options": {
* "getEntities": (context) => [...],
* "i18nKey": "objectLabels"
* }
* }
*/
export function SwitchesWidget(props: WidgetProps) {
const { value, disabled, readonly, onChange, formContext, id, registry } =
props;
// Get configuration from widget options
const i18nKey = useMemo(
() => (props.options?.i18nKey as string | undefined) || "entities",
[props.options],
);
const namespace = useMemo(
() => (props.options?.namespace as string | undefined) || "views/settings",
[props.options],
);
// Try to get formContext from direct prop, options, or registry
const context = useMemo(
() =>
(formContext as FormContext | undefined) ||
(props.options?.formContext as FormContext | undefined) ||
(registry?.formContext as FormContext | undefined),
[formContext, props.options, registry],
);
const availableEntities = useMemo(() => {
const getEntities =
(props.options?.getEntities as
| ((context: FormContext) => string[])
| undefined) || (() => []);
if (context) {
return getEntities(context);
}
return [];
}, [context, props.options]);
const getDisplayLabel = useMemo(
() =>
(props.options?.getDisplayLabel as
| ((entity: string, context?: FormContext) => string)
| undefined) || ((entity: string) => entity),
[props.options],
);
const listClassName = useMemo(
() => props.options?.listClassName as string | undefined,
[props.options],
);
const enableSearch = useMemo(
() => props.options?.enableSearch as boolean | undefined,
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState("");
const filteredEntities = useMemo(() => {
if (!enableSearch || !searchTerm.trim()) {
return availableEntities;
}
const term = searchTerm.toLowerCase();
return availableEntities.filter((entity) => {
const displayLabel = getDisplayLabel(entity, context);
return displayLabel.toLowerCase().includes(term);
});
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) {
onChange([...selectedEntities, entity]);
} else {
onChange(selectedEntities.filter((item) => item !== entity));
}
};
const t = context?.t;
const summary = t
? t(`configForm.${i18nKey}.summary`, {
ns: namespace,
defaultValue: "{{count}} selected",
count: selectedEntities.length,
})
: `${selectedEntities.length} selected`;
const emptyMessage = t
? t(`configForm.${i18nKey}.empty`, {
ns: namespace,
defaultValue: "No items available",
})
: "No items available";
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="w-full justify-start gap-2 pl-0"
disabled={disabled || readonly}
>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
{summary}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
{availableEntities.length === 0 ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<>
{enableSearch && (
<div className="mr-2">
<Input
type="text"
placeholder={t?.("configForm.searchPlaceholder", {
ns: "views/settings",
defaultValue: "Search...",
})}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-2"
/>
</div>
)}
<div className={cn("grid gap-2", listClassName)}>
{filteredEntities.map((entity) => {
const checked = selectedEntities.includes(entity);
const displayLabel = getDisplayLabel(entity, context);
return (
<div
key={entity}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${entity}`} className="text-sm">
{displayLabel}
</label>
<Switch
id={`${id}-${entity}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(value) =>
toggleEntity(entity, !!value)
}
/>
</div>
);
})}
</div>
</>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
@@ -0,0 +1,74 @@
// Tags Widget - For array of strings input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useState, useCallback, useMemo } from "react";
import { LuX } from "react-icons/lu";
export function TagsWidget(props: WidgetProps) {
const { id, value = [], disabled, readonly, onChange, schema } = props;
const [inputValue, setInputValue] = useState("");
const tags = useMemo(() => (Array.isArray(value) ? value : []), [value]);
const addTag = useCallback(() => {
const trimmed = inputValue.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
setInputValue("");
}
}, [inputValue, tags, onChange]);
const removeTag = useCallback(
(tagToRemove: string) => {
onChange(tags.filter((tag: string) => tag !== tagToRemove));
},
[tags, onChange],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{tags.map((tag: string, index: number) => (
<Badge key={`${tag}-${index}`} variant="secondary" className="gap-1">
{tag}
{!disabled && !readonly && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => removeTag(tag)}
>
<LuX className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
{!readonly && (
<Input
id={id}
type="text"
value={inputValue}
disabled={disabled}
placeholder={`Add ${schema.title?.toLowerCase() || "item"}...`}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
/>
)}
</div>
);
}
@@ -0,0 +1,48 @@
// Text Widget - maps to shadcn/ui Input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
export function TextWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
const fieldClassName = getSizedFieldClassName(options, "xs");
return (
<Input
id={id}
className={cn("text-md", fieldClassName)}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,48 @@
// Textarea Widget - maps to shadcn/ui Textarea
import type { WidgetProps } from "@rjsf/utils";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
export function TextareaWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
const fieldClassName = getSizedFieldClassName(options, "md");
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3}
onChange={(e) =>
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,64 @@
import { useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
import { getSizedFieldClassName } from "../utils";
const DEFAULT_TIMEZONE_VALUE = "__browser__";
function getTimezoneList(): string[] {
if (typeof Intl !== "undefined") {
const intl = Intl as typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const supported = intl.supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) {
return [...supported].sort();
}
}
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
return fallback ? [fallback] : ["UTC"];
}
export function TimezoneSelectWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const { t } = useTranslation(["views/settings", "common"]);
const timezones = useMemo(() => getTimezoneList(), []);
const selectedValue = value ? String(value) : DEFAULT_TIMEZONE_VALUE;
const fieldClassName = getSizedFieldClassName(options, "sm");
const defaultLabel = t("configForm.timezone.defaultOption", {
ns: "views/settings",
});
return (
<Select
value={selectedValue}
onValueChange={(next) =>
onChange(next === DEFAULT_TIMEZONE_VALUE ? null : next)
}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={schema.title || defaultLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_TIMEZONE_VALUE}>{defaultLabel}</SelectItem>
{timezones.map((timezone) => (
<SelectItem key={timezone} value={timezone}>
{timezone}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default TimezoneSelectWidget;
@@ -0,0 +1,49 @@
// Zone Switches Widget - For selecting zones via switches
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
function getZoneNames(context: FormContext): string[] {
if (context?.fullCameraConfig) {
const zones = context.fullCameraConfig.zones;
if (typeof zones === "object" && zones !== null) {
// zones is a dict/object, get the keys
return Object.keys(zones).sort();
}
}
return [];
}
function getZoneDisplayName(zoneName: string, context?: FormContext): string {
// Try to get the config from context
// In the config form context, we may not have the full config directly,
// so we'll try to use the zone config if available
if (context?.fullCameraConfig?.zones) {
const zones = context.fullCameraConfig.zones;
if (typeof zones === "object" && zones !== null) {
const zoneConfig = (zones as Record<string, { friendly_name?: string }>)[
zoneName
];
if (zoneConfig?.friendly_name) {
return zoneConfig.friendly_name;
}
}
}
// Fallback to cleaning up the zone name
return String(zoneName).replace(/_/g, " ");
}
export function ZoneSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getZoneNames,
getDisplayLabel: getZoneDisplayName,
i18nKey: "zoneNames",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
}}
/>
);
}