mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 11:54:52 +03:00
add switches widgets and use friendly names
This commit is contained in:
parent
f25b69e3eb
commit
72ab1f93b5
@ -1201,6 +1201,14 @@
|
|||||||
"resetError": "Failed to reset object settings"
|
"resetError": "Failed to reset object settings"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"objectLabels": {
|
||||||
|
"summary": "Selected {{count}}",
|
||||||
|
"empty": "No object labels available"
|
||||||
|
},
|
||||||
|
"zoneNames": {
|
||||||
|
"summary": "Selected {{count}}",
|
||||||
|
"empty": "No zones available"
|
||||||
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"title": "Review Settings",
|
"title": "Review Settings",
|
||||||
"toast": {
|
"toast": {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import axios from "axios";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfigForm } from "../ConfigForm";
|
import { ConfigForm } from "../ConfigForm";
|
||||||
|
import type { UiSchema } from "@rjsf/utils";
|
||||||
import {
|
import {
|
||||||
useConfigOverride,
|
useConfigOverride,
|
||||||
normalizeConfigValue,
|
normalizeConfigValue,
|
||||||
@ -42,6 +43,8 @@ export interface SectionConfig {
|
|||||||
hiddenFields?: string[];
|
hiddenFields?: string[];
|
||||||
/** Fields to show in advanced section */
|
/** Fields to show in advanced section */
|
||||||
advancedFields?: string[];
|
advancedFields?: string[];
|
||||||
|
/** Additional uiSchema overrides */
|
||||||
|
uiSchema?: UiSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseSectionProps {
|
export interface BaseSectionProps {
|
||||||
@ -315,6 +318,7 @@ export function createConfigSection({
|
|||||||
fieldOrder={sectionConfig.fieldOrder}
|
fieldOrder={sectionConfig.fieldOrder}
|
||||||
hiddenFields={sectionConfig.hiddenFields}
|
hiddenFields={sectionConfig.hiddenFields}
|
||||||
advancedFields={sectionConfig.advancedFields}
|
advancedFields={sectionConfig.advancedFields}
|
||||||
|
uiSchema={sectionConfig.uiSchema}
|
||||||
disabled={disabled || isSaving}
|
disabled={disabled || isSaving}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
showSubmit={false}
|
showSubmit={false}
|
||||||
@ -324,6 +328,12 @@ export function createConfigSection({
|
|||||||
cameraName,
|
cameraName,
|
||||||
globalValue,
|
globalValue,
|
||||||
cameraValue,
|
cameraValue,
|
||||||
|
// For widgets that need access to full camera config (e.g., zone names)
|
||||||
|
fullCameraConfig:
|
||||||
|
level === "camera" && cameraName
|
||||||
|
? config?.cameras?.[cameraName]
|
||||||
|
: undefined,
|
||||||
|
t,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -20,14 +20,12 @@ export const MotionSection = createConfigSection({
|
|||||||
"mqtt_off_delay",
|
"mqtt_off_delay",
|
||||||
],
|
],
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
|
sensitivity: ["threshold", "contour_area"],
|
||||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||||
},
|
},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||||
advancedFields: [
|
advancedFields: [
|
||||||
"lightning_threshold",
|
"lightning_threshold",
|
||||||
"improve_contrast",
|
|
||||||
"contour_area",
|
|
||||||
"delta_alpha",
|
"delta_alpha",
|
||||||
"frame_alpha",
|
"frame_alpha",
|
||||||
"frame_height",
|
"frame_height",
|
||||||
|
|||||||
@ -7,13 +7,35 @@ export const ObjectsSection = createConfigSection({
|
|||||||
sectionPath: "objects",
|
sectionPath: "objects",
|
||||||
i18nNamespace: "config/objects",
|
i18nNamespace: "config/objects",
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
|
fieldOrder: ["track", "alert", "detect", "filters"],
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
tracking: ["track", "alert", "detect"],
|
tracking: ["track", "alert", "detect"],
|
||||||
filtering: ["filters", "mask"],
|
filtering: ["filters"],
|
||||||
|
},
|
||||||
|
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||||
|
advancedFields: ["filters"],
|
||||||
|
uiSchema: {
|
||||||
|
track: {
|
||||||
|
"ui:widget": "objectLabels",
|
||||||
|
"ui:options": {
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
genai: {
|
||||||
|
objects: {
|
||||||
|
"ui:widget": "objectLabels",
|
||||||
|
"ui:options": {
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required_zones: {
|
||||||
|
"ui:widget": "zoneNames",
|
||||||
|
"ui:options": {
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
hiddenFields: ["enabled_in_config"],
|
|
||||||
advancedFields: ["filters", "mask"],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,13 @@ export const ReviewSection = createConfigSection({
|
|||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
fieldOrder: ["alerts", "detections"],
|
fieldOrder: ["alerts", "detections"],
|
||||||
fieldGroups: {},
|
fieldGroups: {},
|
||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: [
|
||||||
|
"enabled_in_config",
|
||||||
|
"alerts.labels",
|
||||||
|
"alerts.required_zones",
|
||||||
|
"detections.labels",
|
||||||
|
"detections.required_zones",
|
||||||
|
],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,11 +14,13 @@ import { SwitchWidget } from "./widgets/SwitchWidget";
|
|||||||
import { SelectWidget } from "./widgets/SelectWidget";
|
import { SelectWidget } from "./widgets/SelectWidget";
|
||||||
import { TextWidget } from "./widgets/TextWidget";
|
import { TextWidget } from "./widgets/TextWidget";
|
||||||
import { PasswordWidget } from "./widgets/PasswordWidget";
|
import { PasswordWidget } from "./widgets/PasswordWidget";
|
||||||
import { CheckboxWidget } from "./widgets/CheckboxWidget";
|
|
||||||
import { RangeWidget } from "./widgets/RangeWidget";
|
import { RangeWidget } from "./widgets/RangeWidget";
|
||||||
import { TagsWidget } from "./widgets/TagsWidget";
|
import { TagsWidget } from "./widgets/TagsWidget";
|
||||||
import { ColorWidget } from "./widgets/ColorWidget";
|
import { ColorWidget } from "./widgets/ColorWidget";
|
||||||
import { TextareaWidget } from "./widgets/TextareaWidget";
|
import { TextareaWidget } from "./widgets/TextareaWidget";
|
||||||
|
import { SwitchesWidget } from "./widgets/SwitchesWidget";
|
||||||
|
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
|
||||||
|
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
|
||||||
|
|
||||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||||
@ -45,7 +47,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
TextWidget: TextWidget,
|
TextWidget: TextWidget,
|
||||||
PasswordWidget: PasswordWidget,
|
PasswordWidget: PasswordWidget,
|
||||||
SelectWidget: SelectWidget,
|
SelectWidget: SelectWidget,
|
||||||
CheckboxWidget: CheckboxWidget,
|
CheckboxWidget: SwitchWidget,
|
||||||
// Custom widgets
|
// Custom widgets
|
||||||
switch: SwitchWidget,
|
switch: SwitchWidget,
|
||||||
password: PasswordWidget,
|
password: PasswordWidget,
|
||||||
@ -54,6 +56,9 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
tags: TagsWidget,
|
tags: TagsWidget,
|
||||||
color: ColorWidget,
|
color: ColorWidget,
|
||||||
textarea: TextareaWidget,
|
textarea: TextareaWidget,
|
||||||
|
switches: SwitchesWidget,
|
||||||
|
objectLabels: ObjectLabelSwitchesWidget,
|
||||||
|
zoneNames: ZoneSwitchesWidget,
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
...defaultRegistry.templates,
|
...defaultRegistry.templates,
|
||||||
|
|||||||
@ -49,6 +49,14 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
const isBoolean = schema.type === "boolean";
|
const isBoolean = schema.type === "boolean";
|
||||||
|
|
||||||
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
|
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
|
||||||
|
const suppressMultiSchema =
|
||||||
|
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined)
|
||||||
|
?.suppressMultiSchema === true;
|
||||||
|
|
||||||
|
// Only suppress labels/descriptions if this is a multi-schema field (anyOf/oneOf) with suppressMultiSchema flag
|
||||||
|
// This prevents duplicate labels while still showing the inner field's label
|
||||||
|
const isMultiSchemaWrapper =
|
||||||
|
(schema.anyOf || schema.oneOf) && (suppressMultiSchema || isNullableUnion);
|
||||||
|
|
||||||
// Get translation path for this field
|
// Get translation path for this field
|
||||||
const translationPath = buildTranslationPath(fieldPathId.path);
|
const translationPath = buildTranslationPath(fieldPathId.path);
|
||||||
@ -104,7 +112,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
)}
|
)}
|
||||||
data-field-id={translationPath}
|
data-field-id={translationPath}
|
||||||
>
|
>
|
||||||
{displayLabel && finalLabel && !isBoolean && !isNullableUnion && (
|
{displayLabel && finalLabel && !isBoolean && !isMultiSchemaWrapper && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -126,9 +134,9 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
{finalDescription && !isNullableUnion && (
|
{finalDescription && !isMultiSchemaWrapper && (
|
||||||
<p className="max-w-md text-sm text-muted-foreground">
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
{String(finalDescription)}
|
{finalDescription}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +144,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{finalDescription && !isNullableUnion && (
|
{finalDescription && !isMultiSchemaWrapper && (
|
||||||
<p className="text-sm text-muted-foreground">{finalDescription}</p>
|
<p className="text-sm text-muted-foreground">{finalDescription}</p>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -20,10 +20,15 @@ export function MultiSchemaFieldTemplate<
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
F extends FormContextType = any,
|
F extends FormContextType = any,
|
||||||
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
|
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
|
||||||
const { schema, selector, optionSchemaField } = props;
|
const { schema, selector, optionSchemaField, uiSchema } = props;
|
||||||
|
|
||||||
|
const uiOptions = uiSchema?.["ui:options"] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
|
||||||
|
|
||||||
// Check if this is a simple nullable field that should be handled specially
|
// Check if this is a simple nullable field that should be handled specially
|
||||||
if (isNullableUnionSchema(schema)) {
|
if (isNullableUnionSchema(schema) || suppressMultiSchema) {
|
||||||
// For simple nullable fields, just render the field directly without the dropdown selector
|
// For simple nullable fields, just render the field directly without the dropdown selector
|
||||||
// This handles the case where empty input = null
|
// This handles the case where empty input = null
|
||||||
return <>{optionSchemaField}</>;
|
return <>{optionSchemaField}</>;
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
// Object Label Switches Widget - For selecting objects via switches
|
||||||
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
|
import { SwitchesWidget } from "./SwitchesWidget";
|
||||||
|
import type { FormContext } from "./SwitchesWidget";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
|
||||||
|
function getObjectLabels(context: FormContext): string[] {
|
||||||
|
let cameraLabels: string[] = [];
|
||||||
|
let globalLabels: string[] = [];
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
// context.cameraValue and context.globalValue should be the entire objects section
|
||||||
|
const trackValue = context.cameraValue?.track;
|
||||||
|
if (Array.isArray(trackValue)) {
|
||||||
|
cameraLabels = trackValue.filter(
|
||||||
|
(item): item is string => typeof item === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalTrackValue = context.globalValue?.track;
|
||||||
|
if (Array.isArray(globalTrackValue)) {
|
||||||
|
globalLabels = globalTrackValue.filter(
|
||||||
|
(item): item is string => typeof item === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
|
||||||
|
return [...sourceLabels].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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
web/src/components/config-form/theme/widgets/SwitchesWidget.tsx
Normal file
177
web/src/components/config-form/theme/widgets/SwitchesWidget.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
// Generic Switches Widget - Reusable component for selecting from any list of entities
|
||||||
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
|
|
||||||
|
type FormContext = {
|
||||||
|
cameraValue?: Record<string, unknown>;
|
||||||
|
globalValue?: Record<string, unknown>;
|
||||||
|
fullCameraConfig?: Record<string, unknown>;
|
||||||
|
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 selectedEntities = useMemo(() => normalizeValue(value), [value]);
|
||||||
|
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
|
||||||
|
|
||||||
|
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: "Selected {{count}}",
|
||||||
|
count: selectedEntities.length,
|
||||||
|
})
|
||||||
|
: `Selected ${selectedEntities.length}`;
|
||||||
|
|
||||||
|
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"
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<LuChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{summary}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
{availableEntities.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{availableEntities.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,48 @@
|
|||||||
|
// 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -264,7 +264,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection(section.key)}
|
onClick={() => setActiveSection(section.key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
activeSection === section.key
|
activeSection === section.key
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "hover:bg-muted",
|
: "hover:bg-muted",
|
||||||
|
|||||||
@ -414,7 +414,7 @@ export default function GlobalConfigView() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection(section.key)}
|
onClick={() => setActiveSection(section.key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
activeSection === section.key
|
activeSection === section.key
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "hover:bg-muted",
|
: "hover:bg-muted",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user