mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-23 04:09:04 +03:00
use react-jsonschema-form for UI config
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// ConfigForm - Main RJSF form wrapper component
|
||||
import Form from "@rjsf/core";
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import type { IChangeEvent } from "@rjsf/core";
|
||||
import { frigateTheme } from "./theme";
|
||||
import { transformSchema } from "@/lib/config-schema";
|
||||
import { createErrorTransformer } from "@/lib/config-schema/errorMessages";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export interface ConfigFormProps {
|
||||
/** JSON Schema for the form */
|
||||
schema: RJSFSchema;
|
||||
/** Current form data */
|
||||
formData?: Record<string, unknown>;
|
||||
/** Called when form data changes */
|
||||
onChange?: (data: Record<string, unknown>) => void;
|
||||
/** Called when form is submitted */
|
||||
onSubmit?: (data: Record<string, unknown>) => void;
|
||||
/** Called when form has errors on submit */
|
||||
onError?: (errors: unknown[]) => void;
|
||||
/** Additional uiSchema overrides */
|
||||
uiSchema?: UiSchema;
|
||||
/** Field ordering */
|
||||
fieldOrder?: string[];
|
||||
/** Fields to hide */
|
||||
hiddenFields?: string[];
|
||||
/** Fields marked as advanced (collapsed by default) */
|
||||
advancedFields?: string[];
|
||||
/** Whether form is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether form is read-only */
|
||||
readonly?: boolean;
|
||||
/** Whether to show submit button */
|
||||
showSubmit?: boolean;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
/** Live validation mode */
|
||||
liveValidate?: boolean;
|
||||
/** Form context passed to all widgets */
|
||||
formContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ConfigForm({
|
||||
schema,
|
||||
formData,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onError,
|
||||
uiSchema: customUiSchema,
|
||||
fieldOrder,
|
||||
hiddenFields,
|
||||
advancedFields,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
showSubmit = true,
|
||||
className,
|
||||
liveValidate = false,
|
||||
formContext,
|
||||
}: ConfigFormProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Determine which fields to hide based on advanced toggle
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
if (showAdvanced || !advancedFields || advancedFields.length === 0) {
|
||||
return hiddenFields;
|
||||
}
|
||||
// Hide advanced fields when toggle is off
|
||||
return [...(hiddenFields || []), ...advancedFields];
|
||||
}, [hiddenFields, advancedFields, showAdvanced]);
|
||||
|
||||
// Transform schema and generate uiSchema
|
||||
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
|
||||
() =>
|
||||
transformSchema(schema, {
|
||||
fieldOrder,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
advancedFields: showAdvanced ? advancedFields : [],
|
||||
}),
|
||||
[schema, fieldOrder, effectiveHiddenFields, advancedFields, showAdvanced],
|
||||
);
|
||||
|
||||
// Merge generated uiSchema with custom overrides
|
||||
const finalUiSchema = useMemo(
|
||||
() => ({
|
||||
...generatedUiSchema,
|
||||
...customUiSchema,
|
||||
"ui:submitButtonOptions": showSubmit
|
||||
? { norender: false }
|
||||
: { norender: true },
|
||||
}),
|
||||
[generatedUiSchema, customUiSchema, showSubmit],
|
||||
);
|
||||
|
||||
// Create error transformer for user-friendly error messages
|
||||
const errorTransformer = useMemo(() => createErrorTransformer(), []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: IChangeEvent) => {
|
||||
onChange?.(e.formData);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: IChangeEvent) => {
|
||||
onSubmit?.(e.formData);
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
const hasAdvancedFields = advancedFields && advancedFields.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("config-form", className)}>
|
||||
{hasAdvancedFields && (
|
||||
<div className="mb-4 flex items-center justify-end gap-2">
|
||||
<Switch
|
||||
id="show-advanced"
|
||||
checked={showAdvanced}
|
||||
onCheckedChange={setShowAdvanced}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-advanced"
|
||||
className="cursor-pointer text-sm text-muted-foreground"
|
||||
>
|
||||
{t("configForm.showAdvanced", {
|
||||
defaultValue: "Show Advanced Settings",
|
||||
})}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Form
|
||||
schema={transformedSchema}
|
||||
uiSchema={finalUiSchema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
onError={onError}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
liveValidate={liveValidate}
|
||||
formContext={formContext}
|
||||
transformErrors={errorTransformer}
|
||||
{...frigateTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigForm;
|
||||
@@ -0,0 +1,30 @@
|
||||
// Audio Section Component
|
||||
// Reusable for both global and camera-level audio settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the audio section
|
||||
export const audioSectionConfig: SectionConfig = {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"listen",
|
||||
"filters",
|
||||
"min_volume",
|
||||
"max_not_heard",
|
||||
"num_threads",
|
||||
],
|
||||
fieldGroups: {
|
||||
detection: ["listen", "filters"],
|
||||
sensitivity: ["min_volume", "max_not_heard"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
|
||||
};
|
||||
|
||||
export const AudioSection = createConfigSection({
|
||||
sectionPath: "audio",
|
||||
translationKey: "configForm.audio",
|
||||
defaultConfig: audioSectionConfig,
|
||||
});
|
||||
|
||||
export default AudioSection;
|
||||
@@ -0,0 +1,249 @@
|
||||
// Base Section Component for config form sections
|
||||
// Used as a foundation for reusable section components
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfigForm } from "../ConfigForm";
|
||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuRotateCcw } from "react-icons/lu";
|
||||
import get from "lodash/get";
|
||||
|
||||
export interface SectionConfig {
|
||||
/** Field ordering within the section */
|
||||
fieldOrder?: string[];
|
||||
/** Fields to group together */
|
||||
fieldGroups?: Record<string, string[]>;
|
||||
/** Fields to hide from UI */
|
||||
hiddenFields?: string[];
|
||||
/** Fields to show in advanced section */
|
||||
advancedFields?: string[];
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
/** Whether this is at global or camera level */
|
||||
level: "global" | "camera";
|
||||
/** Camera name (required if level is "camera") */
|
||||
cameraName?: string;
|
||||
/** Whether to show override indicator badge */
|
||||
showOverrideIndicator?: boolean;
|
||||
/** Custom section configuration */
|
||||
sectionConfig?: SectionConfig;
|
||||
/** Whether the section is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether the section is read-only */
|
||||
readonly?: boolean;
|
||||
/** Callback when settings are saved */
|
||||
onSave?: () => void;
|
||||
/** Whether a restart is required after changes */
|
||||
requiresRestart?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateSectionOptions {
|
||||
/** The config path for this section (e.g., "detect", "record") */
|
||||
sectionPath: string;
|
||||
/** Translation key prefix for this section */
|
||||
translationKey: string;
|
||||
/** Default section configuration */
|
||||
defaultConfig: SectionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create reusable config section components
|
||||
*/
|
||||
export function createConfigSection({
|
||||
sectionPath,
|
||||
translationKey,
|
||||
defaultConfig,
|
||||
}: CreateSectionOptions) {
|
||||
return function ConfigSection({
|
||||
level,
|
||||
cameraName,
|
||||
showOverrideIndicator = true,
|
||||
sectionConfig = defaultConfig,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
onSave,
|
||||
requiresRestart = true,
|
||||
}: BaseSectionProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
// Fetch config
|
||||
const { data: config, mutate: refreshConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
// Get section schema using cached hook
|
||||
const sectionSchema = useSectionSchema(sectionPath, level);
|
||||
|
||||
// Get override status
|
||||
const { isOverridden, globalValue, cameraValue, resetToGlobal } =
|
||||
useConfigOverride({
|
||||
config,
|
||||
cameraName: level === "camera" ? cameraName : undefined,
|
||||
sectionPath,
|
||||
});
|
||||
|
||||
// Get current form data
|
||||
const formData = useMemo(() => {
|
||||
if (!config) return {};
|
||||
|
||||
if (level === "camera" && cameraName) {
|
||||
return get(config.cameras?.[cameraName], sectionPath) || {};
|
||||
}
|
||||
|
||||
return get(config, sectionPath) || {};
|
||||
}, [config, level, cameraName]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(
|
||||
async (data: Record<string, unknown>) => {
|
||||
try {
|
||||
const basePath =
|
||||
level === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: requiresRestart ? 1 : 0,
|
||||
config_data: {
|
||||
[basePath]: data,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(`${translationKey}.toast.success`, {
|
||||
defaultValue: "Settings saved successfully",
|
||||
}),
|
||||
);
|
||||
|
||||
refreshConfig();
|
||||
onSave?.();
|
||||
} catch (error) {
|
||||
// Parse Pydantic validation errors from API response
|
||||
if (axios.isAxiosError(error) && error.response?.data) {
|
||||
const responseData = error.response.data;
|
||||
// Pydantic errors come as { detail: [...] } or { message: "..." }
|
||||
if (responseData.detail && Array.isArray(responseData.detail)) {
|
||||
const validationMessages = responseData.detail
|
||||
.map((err: { loc?: string[]; msg?: string }) => {
|
||||
const field = err.loc?.slice(1).join(".") || "unknown";
|
||||
return `${field}: ${err.msg || "Invalid value"}`;
|
||||
})
|
||||
.join(", ");
|
||||
toast.error(
|
||||
t(`${translationKey}.toast.validationError`, {
|
||||
defaultValue: `Validation failed: ${validationMessages}`,
|
||||
}),
|
||||
);
|
||||
} else if (responseData.message) {
|
||||
toast.error(responseData.message);
|
||||
} else {
|
||||
toast.error(
|
||||
t(`${translationKey}.toast.error`, {
|
||||
defaultValue: "Failed to save settings",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t(`${translationKey}.toast.error`, {
|
||||
defaultValue: "Failed to save settings",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[level, cameraName, requiresRestart, t, refreshConfig, onSave],
|
||||
);
|
||||
|
||||
// Handle reset to global
|
||||
const handleResetToGlobal = useCallback(async () => {
|
||||
if (level !== "camera" || !cameraName) return;
|
||||
|
||||
try {
|
||||
const basePath = `cameras.${cameraName}.${sectionPath}`;
|
||||
|
||||
// Reset by setting to null/undefined or removing the override
|
||||
await axios.put("config/set", {
|
||||
requires_restart: requiresRestart ? 1 : 0,
|
||||
config_data: {
|
||||
[basePath]: resetToGlobal(),
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(`${translationKey}.toast.resetSuccess`, {
|
||||
defaultValue: "Reset to global defaults",
|
||||
}),
|
||||
);
|
||||
|
||||
refreshConfig();
|
||||
} catch {
|
||||
toast.error(
|
||||
t(`${translationKey}.toast.resetError`, {
|
||||
defaultValue: "Failed to reset settings",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [level, cameraName, requiresRestart, t, refreshConfig, resetToGlobal]);
|
||||
|
||||
if (!sectionSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = t(`${translationKey}.title`, {
|
||||
defaultValue: sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
{showOverrideIndicator && level === "camera" && isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("common.overridden", { defaultValue: "Overridden" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{level === "camera" && isOverridden && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetToGlobal}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuRotateCcw className="h-4 w-4" />
|
||||
{t("common.resetToGlobal", { defaultValue: "Reset to Global" })}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConfigForm
|
||||
schema={sectionSchema}
|
||||
formData={formData}
|
||||
onSubmit={handleSubmit}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
hiddenFields={sectionConfig.hiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
formContext={{
|
||||
level,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Detect Section Component
|
||||
// Reusable for both global and camera-level detect settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the detect section
|
||||
export const detectSectionConfig: SectionConfig = {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"fps",
|
||||
"width",
|
||||
"height",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
fieldGroups: {
|
||||
resolution: ["width", "height"],
|
||||
tracking: ["min_initialized", "max_disappeared"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
};
|
||||
|
||||
export const DetectSection = createConfigSection({
|
||||
sectionPath: "detect",
|
||||
translationKey: "configForm.detect",
|
||||
defaultConfig: detectSectionConfig,
|
||||
});
|
||||
|
||||
export default DetectSection;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Live Section Component
|
||||
// Reusable for both global and camera-level live settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the live section
|
||||
export const liveSectionConfig: SectionConfig = {
|
||||
fieldOrder: ["stream_name", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["quality"],
|
||||
};
|
||||
|
||||
export const LiveSection = createConfigSection({
|
||||
sectionPath: "live",
|
||||
translationKey: "configForm.live",
|
||||
defaultConfig: liveSectionConfig,
|
||||
});
|
||||
|
||||
export default LiveSection;
|
||||
@@ -0,0 +1,42 @@
|
||||
// Motion Section Component
|
||||
// Reusable for both global and camera-level motion settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the motion section
|
||||
export const motionSectionConfig: SectionConfig = {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mask",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
fieldGroups: {
|
||||
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
|
||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
};
|
||||
|
||||
export const MotionSection = createConfigSection({
|
||||
sectionPath: "motion",
|
||||
translationKey: "configForm.motion",
|
||||
defaultConfig: motionSectionConfig,
|
||||
});
|
||||
|
||||
export default MotionSection;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Notifications Section Component
|
||||
// Reusable for both global and camera-level notification settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the notifications section
|
||||
export const notificationsSectionConfig: SectionConfig = {
|
||||
fieldOrder: ["enabled", "email"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [],
|
||||
};
|
||||
|
||||
export const NotificationsSection = createConfigSection({
|
||||
sectionPath: "notifications",
|
||||
translationKey: "configForm.notifications",
|
||||
defaultConfig: notificationsSectionConfig,
|
||||
});
|
||||
|
||||
export default NotificationsSection;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Objects Section Component
|
||||
// Reusable for both global and camera-level objects settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the objects section
|
||||
export const objectsSectionConfig: SectionConfig = {
|
||||
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
|
||||
fieldGroups: {
|
||||
tracking: ["track", "alert", "detect"],
|
||||
filtering: ["filters", "mask"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["filters", "mask"],
|
||||
};
|
||||
|
||||
export const ObjectsSection = createConfigSection({
|
||||
sectionPath: "objects",
|
||||
translationKey: "configForm.objects",
|
||||
defaultConfig: objectsSectionConfig,
|
||||
});
|
||||
|
||||
export default ObjectsSection;
|
||||
@@ -0,0 +1,32 @@
|
||||
// Record Section Component
|
||||
// Reusable for both global and camera-level record settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the record section
|
||||
export const recordSectionConfig: SectionConfig = {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"expire_interval",
|
||||
"continuous",
|
||||
"motion",
|
||||
"alerts",
|
||||
"detections",
|
||||
"preview",
|
||||
"export",
|
||||
],
|
||||
fieldGroups: {
|
||||
retention: ["continuous", "motion"],
|
||||
events: ["alerts", "detections"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["expire_interval", "preview", "export"],
|
||||
};
|
||||
|
||||
export const RecordSection = createConfigSection({
|
||||
sectionPath: "record",
|
||||
translationKey: "configForm.record",
|
||||
defaultConfig: recordSectionConfig,
|
||||
});
|
||||
|
||||
export default RecordSection;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Review Section Component
|
||||
// Reusable for both global and camera-level review settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the review section
|
||||
export const reviewSectionConfig: SectionConfig = {
|
||||
fieldOrder: ["alerts", "detections"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [],
|
||||
};
|
||||
|
||||
export const ReviewSection = createConfigSection({
|
||||
sectionPath: "review",
|
||||
translationKey: "configForm.review",
|
||||
defaultConfig: reviewSectionConfig,
|
||||
});
|
||||
|
||||
export default ReviewSection;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Snapshots Section Component
|
||||
// Reusable for both global and camera-level snapshots settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the snapshots section
|
||||
export const snapshotsSectionConfig: SectionConfig = {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"retain",
|
||||
],
|
||||
fieldGroups: {
|
||||
display: ["bounding_box", "crop", "quality", "timestamp"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["quality", "retain"],
|
||||
};
|
||||
|
||||
export const SnapshotsSection = createConfigSection({
|
||||
sectionPath: "snapshots",
|
||||
translationKey: "configForm.snapshots",
|
||||
defaultConfig: snapshotsSectionConfig,
|
||||
});
|
||||
|
||||
export default SnapshotsSection;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Timestamp Section Component
|
||||
// Reusable for both global and camera-level timestamp_style settings
|
||||
|
||||
import { createConfigSection, type SectionConfig } from "./BaseSection";
|
||||
|
||||
// Configuration for the timestamp_style section
|
||||
export const timestampSectionConfig: SectionConfig = {
|
||||
fieldOrder: ["position", "format", "color", "thickness", "effect"],
|
||||
fieldGroups: {
|
||||
appearance: ["color", "thickness", "effect"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["thickness", "effect"],
|
||||
};
|
||||
|
||||
export const TimestampSection = createConfigSection({
|
||||
sectionPath: "timestamp_style",
|
||||
translationKey: "configForm.timestampStyle",
|
||||
defaultConfig: timestampSectionConfig,
|
||||
});
|
||||
|
||||
export default TimestampSection;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Config Form Section Components
|
||||
// Reusable components for both global and camera-level settings
|
||||
|
||||
export {
|
||||
createConfigSection,
|
||||
type BaseSectionProps,
|
||||
type SectionConfig,
|
||||
type CreateSectionOptions,
|
||||
} from "./BaseSection";
|
||||
|
||||
export { DetectSection, detectSectionConfig } from "./DetectSection";
|
||||
export { RecordSection, recordSectionConfig } from "./RecordSection";
|
||||
export { SnapshotsSection, snapshotsSectionConfig } from "./SnapshotsSection";
|
||||
export { MotionSection, motionSectionConfig } from "./MotionSection";
|
||||
export { ObjectsSection, objectsSectionConfig } from "./ObjectsSection";
|
||||
export { ReviewSection, reviewSectionConfig } from "./ReviewSection";
|
||||
export { AudioSection, audioSectionConfig } from "./AudioSection";
|
||||
export {
|
||||
NotificationsSection,
|
||||
notificationsSectionConfig,
|
||||
} from "./NotificationsSection";
|
||||
export { LiveSection, liveSectionConfig } from "./LiveSection";
|
||||
export { TimestampSection, timestampSectionConfig } from "./TimestampSection";
|
||||
@@ -0,0 +1,67 @@
|
||||
// Utilities for handling anyOf with null patterns
|
||||
import type { StrictRJSFSchema } from "@rjsf/utils";
|
||||
|
||||
/**
|
||||
* Checks if a schema is anyOf with exactly [PrimitiveType, null]
|
||||
* where the primitive has no additional constraints
|
||||
*/
|
||||
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
|
||||
if (
|
||||
!schema.anyOf ||
|
||||
!Array.isArray(schema.anyOf) ||
|
||||
schema.anyOf.length !== 2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = schema.anyOf;
|
||||
let hasNull = false;
|
||||
let simpleType: StrictRJSFSchema | null = null;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const item of items) {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemSchema = item as StrictRJSFSchema;
|
||||
|
||||
if (itemSchema.type === "null") {
|
||||
hasNull = true;
|
||||
} else if (
|
||||
itemSchema.type &&
|
||||
!("$ref" in itemSchema) &&
|
||||
!("additionalProperties" in itemSchema) &&
|
||||
!("items" in itemSchema) &&
|
||||
!("pattern" in itemSchema) &&
|
||||
!("minimum" in itemSchema) &&
|
||||
!("maximum" in itemSchema) &&
|
||||
!("exclusiveMinimum" in itemSchema) &&
|
||||
!("exclusiveMaximum" in itemSchema)
|
||||
) {
|
||||
simpleType = itemSchema;
|
||||
}
|
||||
}
|
||||
|
||||
return hasNull && simpleType !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the non-null schema from an anyOf containing [Type, null]
|
||||
*/
|
||||
export function getNonNullSchema(
|
||||
schema: StrictRJSFSchema,
|
||||
): StrictRJSFSchema | null {
|
||||
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(schema.anyOf.find(
|
||||
(item) =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
(item as StrictRJSFSchema).type !== "null",
|
||||
) as StrictRJSFSchema) || null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Custom RJSF Theme for Frigate
|
||||
// Maps RJSF templates and widgets to shadcn/ui components
|
||||
|
||||
import type {
|
||||
WidgetProps,
|
||||
FieldTemplateProps,
|
||||
RegistryWidgetsType,
|
||||
RegistryFieldsType,
|
||||
TemplatesType,
|
||||
} from "@rjsf/utils";
|
||||
import { getDefaultRegistry } from "@rjsf/core";
|
||||
|
||||
import { SwitchWidget } from "./widgets/SwitchWidget";
|
||||
import { SelectWidget } from "./widgets/SelectWidget";
|
||||
import { TextWidget } from "./widgets/TextWidget";
|
||||
import { PasswordWidget } from "./widgets/PasswordWidget";
|
||||
import { CheckboxWidget } from "./widgets/CheckboxWidget";
|
||||
import { RangeWidget } from "./widgets/RangeWidget";
|
||||
import { TagsWidget } from "./widgets/TagsWidget";
|
||||
import { ColorWidget } from "./widgets/ColorWidget";
|
||||
import { TextareaWidget } from "./widgets/TextareaWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
|
||||
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
|
||||
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
|
||||
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
|
||||
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
|
||||
import { SubmitButton } from "./templates/SubmitButton";
|
||||
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
templates: Partial<TemplatesType>;
|
||||
fields: RegistryFieldsType;
|
||||
}
|
||||
|
||||
const defaultRegistry = getDefaultRegistry();
|
||||
|
||||
export const frigateTheme: FrigateTheme = {
|
||||
widgets: {
|
||||
...defaultRegistry.widgets,
|
||||
// Override default widgets with shadcn/ui styled versions
|
||||
TextWidget: TextWidget,
|
||||
PasswordWidget: PasswordWidget,
|
||||
SelectWidget: SelectWidget,
|
||||
CheckboxWidget: CheckboxWidget,
|
||||
// Custom widgets
|
||||
switch: SwitchWidget,
|
||||
password: PasswordWidget,
|
||||
select: SelectWidget,
|
||||
range: RangeWidget,
|
||||
tags: TagsWidget,
|
||||
color: ColorWidget,
|
||||
textarea: TextareaWidget,
|
||||
},
|
||||
templates: {
|
||||
...defaultRegistry.templates,
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
ObjectFieldTemplate: ObjectFieldTemplate,
|
||||
ArrayFieldTemplate: ArrayFieldTemplate,
|
||||
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
|
||||
DescriptionFieldTemplate: DescriptionFieldTemplate,
|
||||
TitleFieldTemplate: TitleFieldTemplate,
|
||||
ErrorListTemplate: ErrorListTemplate,
|
||||
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
||||
ButtonTemplates: {
|
||||
...defaultRegistry.templates.ButtonTemplates,
|
||||
SubmitButton: SubmitButton,
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
...defaultRegistry.fields,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// RJSF Custom Theme
|
||||
// Maps RJSF components to existing shadcn/ui components
|
||||
|
||||
export { frigateTheme } from "./frigateTheme";
|
||||
export type { FrigateTheme } from "./frigateTheme";
|
||||
@@ -0,0 +1,101 @@
|
||||
// Array Field Template - renders array fields with add/remove controls
|
||||
import type { ArrayFieldTemplateProps } from "@rjsf/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { LuPlus, LuTrash2, LuGripVertical } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ArrayItem {
|
||||
key: string;
|
||||
index: number;
|
||||
children: React.ReactNode;
|
||||
hasRemove: boolean;
|
||||
onDropIndexClick: (index: number) => () => void;
|
||||
}
|
||||
|
||||
export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
|
||||
const { items, canAdd, onAddClick, disabled, readonly, schema } = props;
|
||||
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// Simple items (strings, numbers) render inline
|
||||
const isSimpleType =
|
||||
schema.items &&
|
||||
typeof schema.items === "object" &&
|
||||
"type" in schema.items &&
|
||||
["string", "number", "integer", "boolean"].includes(
|
||||
schema.items.type as string,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && !canAdd && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("no_items", { ns: "common", defaultValue: "No items" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(items as unknown as ArrayItem[]).map((element: ArrayItem) => (
|
||||
<div
|
||||
key={element.key}
|
||||
className={cn("flex items-start gap-2", !isSimpleType && "flex-col")}
|
||||
>
|
||||
{isSimpleType ? (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<LuGripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
||||
<div className="flex-1">{element.children}</div>
|
||||
{element.hasRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={element.onDropIndexClick(element.index)}
|
||||
disabled={disabled || readonly}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="w-full">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<LuGripVertical className="mt-2 h-4 w-4 cursor-move text-muted-foreground" />
|
||||
<div className="flex-1">{element.children}</div>
|
||||
{element.hasRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={element.onDropIndexClick(element.index)}
|
||||
disabled={disabled || readonly}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canAdd && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddClick}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Base Input Template - default input wrapper
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function BaseInputTemplate(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
} = props;
|
||||
|
||||
const inputType = type || "text";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (inputType === "number") {
|
||||
const num = parseFloat(val);
|
||||
onChange(val === "" ? undefined : isNaN(num) ? undefined : num);
|
||||
} else {
|
||||
onChange(val === "" ? undefined : val);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || ""}
|
||||
onChange={handleChange}
|
||||
onBlur={(e) => onBlur(id, e.target.value)}
|
||||
onFocus={(e) => onFocus(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Description Field Template
|
||||
import type { DescriptionFieldProps } from "@rjsf/utils";
|
||||
|
||||
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
|
||||
const { description, id } = props;
|
||||
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p id={id} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Error List Template - displays form-level errors
|
||||
import type { ErrorListProps, RJSFValidationError } from "@rjsf/utils";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ErrorListTemplate(props: ErrorListProps) {
|
||||
const { errors } = props;
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
if (!errors || errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<LuCircleAlert className="h-4 w-4" />
|
||||
<AlertTitle>{t("validation_errors", { ns: "common" })}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1">
|
||||
{errors.map((error: RJSFValidationError, index: number) => (
|
||||
<li key={index} className="text-sm">
|
||||
{error.property && (
|
||||
<span className="font-medium">{error.property}: </span>
|
||||
)}
|
||||
{error.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Field Template - wraps each form field with label and description
|
||||
import type { FieldTemplateProps } from "@rjsf/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
schema,
|
||||
uiSchema,
|
||||
} = props;
|
||||
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
}
|
||||
|
||||
// Get UI options
|
||||
const uiOptions = uiSchema?.["ui:options"] || {};
|
||||
const isAdvanced = uiOptions.advanced === true;
|
||||
|
||||
// Boolean fields (switches) render label inline
|
||||
const isBoolean = schema.type === "boolean";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2",
|
||||
isAdvanced && "border-l-2 border-muted pl-4",
|
||||
isBoolean && "flex items-center justify-between gap-4",
|
||||
)}
|
||||
>
|
||||
{displayLabel && label && !isBoolean && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{isBoolean ? (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
{displayLabel && label && (
|
||||
<Label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
|
||||
{errors}
|
||||
{help}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
|
||||
// Renders simple nullable types as single inputs instead of dropdowns
|
||||
|
||||
import type {
|
||||
MultiSchemaFieldTemplateProps,
|
||||
StrictRJSFSchema,
|
||||
FormContextType,
|
||||
} from "@rjsf/utils";
|
||||
import { isSimpleNullableField } from "../fields/nullableUtils";
|
||||
|
||||
/**
|
||||
* Custom MultiSchemaFieldTemplate that:
|
||||
* 1. Renders simple anyOf [Type, null] fields as single inputs
|
||||
* 2. Falls back to default behavior for complex types
|
||||
*/
|
||||
export function MultiSchemaFieldTemplate<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = StrictRJSFSchema,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
F extends FormContextType = any,
|
||||
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
|
||||
const { schema, selector, optionSchemaField } = props;
|
||||
|
||||
// Check if this is a simple nullable field that should be handled specially
|
||||
if (isSimpleNullableField(schema)) {
|
||||
// For simple nullable fields, just render the field directly without the dropdown selector
|
||||
// This handles the case where empty input = null
|
||||
return <>{optionSchemaField}</>;
|
||||
}
|
||||
|
||||
// For all other cases, render with both selector and field (default MultiSchemaField behavior)
|
||||
return (
|
||||
<>
|
||||
{selector}
|
||||
{optionSchemaField}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Object Field Template - renders nested object fields
|
||||
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
|
||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
const { title, description, properties } = props;
|
||||
|
||||
// Check if this is a root-level object
|
||||
const isRoot = !title;
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Check for advanced section grouping
|
||||
const advancedProps = properties.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
);
|
||||
const regularProps = properties.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
);
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Root level renders children directly
|
||||
if (isRoot) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{regularProps.map((element) => (
|
||||
<div key={element.name}>{element.content}</div>
|
||||
))}
|
||||
|
||||
{advancedProps.length > 0 && (
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2">
|
||||
{showAdvanced ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Advanced Settings ({advancedProps.length})
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{advancedProps.map((element) => (
|
||||
<div key={element.name}>{element.content}</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nested objects render as collapsible cards
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{regularProps.map((element) => (
|
||||
<div key={element.name}>{element.content}</div>
|
||||
))}
|
||||
|
||||
{advancedProps.length > 0 && (
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Advanced ({advancedProps.length})
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{advancedProps.map((element) => (
|
||||
<div key={element.name}>{element.content}</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Submit Button Template
|
||||
import type { SubmitButtonProps } from "@rjsf/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSave } from "react-icons/lu";
|
||||
|
||||
export function SubmitButton(props: SubmitButtonProps) {
|
||||
const { uiSchema } = props;
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const submitText =
|
||||
(uiSchema?.["ui:options"]?.submitText as string) ||
|
||||
t("save", { ns: "common" });
|
||||
|
||||
return (
|
||||
<Button type="submit" className="gap-2">
|
||||
<LuSave className="h-4 w-4" />
|
||||
{submitText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Title Field Template
|
||||
import type { TitleFieldProps } from "@rjsf/utils";
|
||||
|
||||
export function TitleFieldTemplate(props: TitleFieldProps) {
|
||||
const { title, id, required } = props;
|
||||
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<h3 id={id} className="text-lg font-semibold">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
@@ -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,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,55 @@
|
||||
// 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";
|
||||
|
||||
export function PasswordWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
} = props;
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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="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,49 @@
|
||||
// Select Widget - maps to shadcn/ui Select
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function SelectWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
placeholder,
|
||||
schema,
|
||||
} = props;
|
||||
|
||||
const { enumOptions = [] } = options;
|
||||
|
||||
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="w-full">
|
||||
<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,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,34 @@
|
||||
// Text Widget - maps to shadcn/ui Input
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function TextWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Textarea Widget - maps to shadcn/ui Textarea
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function TextareaWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
rows={(options.rows as number) || 3}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user