mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +03:00
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:
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user