diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 72200ea1c..4b716ccd8 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -922,10 +922,11 @@ "form": { "name": { "title": "Name", - "placeholder": "Enter trigger name", + "placeholder": "Name this trigger", + "description": "Enter a unique name or description to identify this trigger", "error": { - "minLength": "Name must be at least 2 characters long.", - "invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.", + "minLength": "Field must be at least 2 characters long.", + "invalidCharacters": "Field can only contain letters, numbers, underscores, and hyphens.", "alreadyExists": "A trigger with this name already exists for this camera." } }, @@ -934,18 +935,15 @@ }, "type": { "title": "Type", - "placeholder": "Select trigger type" - }, - "friendly_name": { - "title": "Friendly Name", - "placeholder": "Name or describe this trigger", - "description": "An optional friendly name or descriptive text for this trigger." + "placeholder": "Select trigger type", + "description": "Trigger when a similar tracked object description is detected", + "thumbnail": "Trigger when a similar tracked object thumbnail is detected" }, "content": { "title": "Content", - "imagePlaceholder": "Select an image", + "imagePlaceholder": "Select a thumbnail", "textPlaceholder": "Enter text content", - "imageDesc": "Select an image to trigger this action when a similar image is detected.", + "imageDesc": "Only the most recent 100 thumbnails are displayed. If you can't find your desired thumbnail, please review earlier objects in Explore and set up a trigger from the menu there.", "textDesc": "Enter text to trigger this action when a similar tracked object description is detected.", "error": { "required": "Content is required." @@ -953,6 +951,7 @@ }, "threshold": { "title": "Threshold", + "desc": "Set the similarity threshold for this trigger. A higher threshold means a closer match is required to fire the trigger.", "error": { "min": "Threshold must be at least 0", "max": "Threshold must be at most 1" @@ -967,6 +966,23 @@ } } }, + "wizard": { + "title": "Create Trigger", + "step1": { + "description": "Configure the basic settings for your trigger." + }, + "step2": { + "description": "Set up the content that will trigger this action." + }, + "step3": { + "description": "Configure the threshold and actions for this trigger." + }, + "steps": { + "nameAndType": "Name and Type", + "configureData": "Configure Data", + "thresholdAndActions": "Threshold and Actions" + } + }, "toast": { "success": { "createTrigger": "Trigger {{name}} created successfully.", diff --git a/web/src/components/trigger/TriggerWizardDialog.tsx b/web/src/components/trigger/TriggerWizardDialog.tsx new file mode 100644 index 000000000..a3970758c --- /dev/null +++ b/web/src/components/trigger/TriggerWizardDialog.tsx @@ -0,0 +1,242 @@ +import { useTranslation } from "react-i18next"; +import StepIndicator from "../indicators/StepIndicator"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { useReducer } from "react"; +import Step1NameAndType, { + Step1FormData, +} from "@/components/trigger/wizard/Step1NameAndType"; +import Step2ConfigureData, { + Step2FormData, +} from "@/components/trigger/wizard/Step2ConfigureData"; +import Step3ThresholdAndActions, { + Step3FormData, +} from "@/components/trigger/wizard/Step3ThresholdAndActions"; +import { cn } from "@/lib/utils"; +import { isDesktop } from "react-device-detect"; +import { Trigger, TriggerAction, TriggerType } from "@/types/trigger"; + +const TRIGGER_STEPS = [ + "wizard.steps.nameAndType", + "wizard.steps.configureData", + "wizard.steps.thresholdAndActions", +]; + +type TriggerWizardDialogProps = { + open: boolean; + onClose: () => void; + selectedCamera: string; + trigger?: Trigger | null; + onCreate: ( + enabled: boolean, + name: string, + type: TriggerType, + data: string, + threshold: number, + actions: TriggerAction[], + friendly_name: string, + ) => void; + onEdit: (trigger: Trigger) => void; + isLoading?: boolean; +}; + +type WizardState = { + currentStep: number; + step1Data?: Step1FormData; + step2Data?: Step2FormData; + step3Data?: Step3FormData; +}; + +type WizardAction = + | { type: "NEXT_STEP"; payload?: Partial } + | { type: "PREVIOUS_STEP" } + | { type: "SET_STEP_1"; payload: Step1FormData } + | { type: "SET_STEP_2"; payload: Step2FormData } + | { type: "SET_STEP_3"; payload: Step3FormData } + | { type: "RESET" }; + +const initialState: WizardState = { + currentStep: 0, +}; + +function wizardReducer(state: WizardState, action: WizardAction): WizardState { + switch (action.type) { + case "SET_STEP_1": + return { + ...state, + step1Data: action.payload, + step2Data: undefined, + step3Data: undefined, + currentStep: 1, + }; + case "SET_STEP_2": + return { + ...state, + step2Data: action.payload, + currentStep: 2, + }; + case "SET_STEP_3": + return { + ...state, + step3Data: action.payload, + currentStep: 3, + }; + case "NEXT_STEP": + return { + ...state, + ...action.payload, + currentStep: state.currentStep + 1, + }; + case "PREVIOUS_STEP": + return { + ...state, + currentStep: Math.max(0, state.currentStep - 1), + }; + case "RESET": + return initialState; + default: + return state; + } +} + +export default function TriggerWizardDialog({ + open, + onClose, + selectedCamera, + trigger, + onCreate, + onEdit, + isLoading, +}: TriggerWizardDialogProps) { + const { t } = useTranslation(["views/settings"]); + + const [wizardState, dispatch] = useReducer(wizardReducer, initialState); + + const handleStep1Next = (data: Step1FormData) => { + dispatch({ type: "SET_STEP_1", payload: data }); + }; + + const handleStep2Next = (data: Step2FormData) => { + dispatch({ type: "SET_STEP_2", payload: data }); + }; + + const handleStep3Next = (data: Step3FormData) => { + // Combine all step data and call the appropriate callback + const combinedData = { + ...wizardState.step1Data!, + ...wizardState.step2Data!, + ...data, + }; + + if (trigger) { + onEdit(combinedData); + } else { + onCreate( + combinedData.enabled, + combinedData.name, + combinedData.type, + combinedData.data, + combinedData.threshold, + combinedData.actions, + combinedData.friendly_name || "", + ); + } + handleClose(); + }; + + const handleBack = () => { + dispatch({ type: "PREVIOUS_STEP" }); + }; + + const handleClose = () => { + dispatch({ type: "RESET" }); + onClose(); + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + > + { + e.preventDefault(); + }} + > + + + {t("triggers.wizard.title")} + {wizardState.currentStep === 0 && ( + + {t("triggers.wizard.step1.description")} + + )} + {wizardState.currentStep === 1 && ( + + {t("triggers.wizard.step2.description")} + + )} + {wizardState.currentStep === 2 && ( + + {t("triggers.wizard.step3.description")} + + )} + + +
+ {wizardState.currentStep === 0 && ( + + )} + {wizardState.currentStep === 1 && wizardState.step1Data && ( + + )} + {wizardState.currentStep === 2 && + wizardState.step1Data && + wizardState.step2Data && ( + + )} +
+
+
+ ); +} diff --git a/web/src/components/trigger/wizard/Step1NameAndType.tsx b/web/src/components/trigger/wizard/Step1NameAndType.tsx new file mode 100644 index 000000000..f9bb9afd3 --- /dev/null +++ b/web/src/components/trigger/wizard/Step1NameAndType.tsx @@ -0,0 +1,200 @@ +import { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import useSWR from "swr"; +import NameAndIdFields from "@/components/ui/NameAndIdFields"; +import { Form, FormDescription } from "@/components/ui/form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Trigger, TriggerType } from "@/types/trigger"; + +export type Step1FormData = { + enabled: boolean; + name: string; + friendly_name: string; + type: TriggerType; +}; + +type Step1NameAndTypeProps = { + initialData?: Step1FormData; + trigger?: Trigger | null; + selectedCamera: string; + onNext: (data: Step1FormData) => void; + onCancel: () => void; +}; + +export default function Step1NameAndType({ + initialData, + trigger, + selectedCamera, + onNext, + onCancel, +}: Step1NameAndTypeProps) { + const { t } = useTranslation("views/settings"); + const { data: config } = useSWR("config"); + + const existingTriggerNames = useMemo(() => { + if ( + !config || + !selectedCamera || + !config.cameras[selectedCamera]?.semantic_search?.triggers + ) { + return []; + } + return Object.keys(config.cameras[selectedCamera].semantic_search.triggers); + }, [config, selectedCamera]); + + const existingTriggerFriendlyNames = useMemo(() => { + if ( + !config || + !selectedCamera || + !config.cameras[selectedCamera]?.semantic_search?.triggers + ) { + return []; + } + return Object.values( + config.cameras[selectedCamera].semantic_search.triggers, + ).map((trigger) => trigger.friendly_name); + }, [config, selectedCamera]); + + const formSchema = z.object({ + enabled: z.boolean(), + name: z + .string() + .min(2, t("triggers.dialog.form.name.error.minLength")) + .regex( + /^[a-zA-Z0-9_-]+$/, + t("triggers.dialog.form.name.error.invalidCharacters"), + ) + .refine( + (value) => + !existingTriggerNames.includes(value) || value === trigger?.name, + t("triggers.dialog.form.name.error.alreadyExists"), + ), + friendly_name: z + .string() + .min(2, t("triggers.dialog.form.name.error.minLength")) + .refine( + (value) => + !existingTriggerFriendlyNames.includes(value) || + value === trigger?.friendly_name, + t("triggers.dialog.form.name.error.alreadyExists"), + ), + type: z.enum(["description", "thumbnail"]), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + enabled: true, + name: initialData?.name ?? trigger?.name ?? "", + friendly_name: initialData?.friendly_name ?? trigger?.friendly_name ?? "", + type: initialData?.type ?? trigger?.type ?? "description", + }, + }); + + const onSubmit = (values: z.infer) => { + onNext({ + enabled: true, + name: values.name, + friendly_name: values.friendly_name || "", + type: values.type, + }); + }; + + useEffect(() => { + if (initialData) { + form.reset(initialData); + } else if (trigger) { + form.reset({ + enabled: trigger.enabled, + name: trigger.name, + friendly_name: trigger.friendly_name || "", + type: trigger.type, + }); + } + }, [initialData, trigger, form]); + + return ( +
+ + + + ( + + {t("triggers.dialog.form.type.title")} + + + {t("triggers.dialog.form.type.description")} + + + + )} + /> + +
+ + +
+ + + ); +} diff --git a/web/src/components/trigger/wizard/Step2ConfigureData.tsx b/web/src/components/trigger/wizard/Step2ConfigureData.tsx new file mode 100644 index 000000000..7cbb74cad --- /dev/null +++ b/web/src/components/trigger/wizard/Step2ConfigureData.tsx @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import ImagePicker from "@/components/overlay/ImagePicker"; +import { TriggerType } from "@/types/trigger"; + +export type Step2FormData = { + data: string; +}; + +type Step2ConfigureDataProps = { + initialData?: Step2FormData; + triggerType: TriggerType; + selectedCamera: string; + onNext: (data: Step2FormData) => void; + onBack: () => void; +}; + +export default function Step2ConfigureData({ + initialData, + triggerType, + selectedCamera, + onNext, + onBack, +}: Step2ConfigureDataProps) { + const { t } = useTranslation("views/settings"); + + const formSchema = z.object({ + data: z.string().min(1, t("triggers.dialog.form.content.error.required")), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + data: initialData?.data ?? "", + }, + }); + + const onSubmit = (values: z.infer) => { + onNext({ + data: values.data, + }); + }; + + useEffect(() => { + if (initialData) { + form.reset(initialData); + } + }, [initialData, form]); + + return ( +
+ + ( + + {triggerType === "thumbnail" ? ( + <> + + {t("triggers.dialog.form.content.imagePlaceholder")} + + + + + + {t("triggers.dialog.form.content.imageDesc")} + + + ) : ( + <> + +