diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 924dc0d0e..2f976fb0c 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -93,7 +93,14 @@ } }, "label": { - "back": "Go back" + "back": "Go back", + "hide": "Hide {{item}}", + "show": "Show {{item}}", + "ID": "ID" + }, + "field": { + "optional": "Optional", + "internalID": "The Internal ID Frigate uses in the configuration and database" }, "button": { "apply": "Apply", diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 293e43db1..a40e62db7 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -112,6 +112,7 @@ }, "imagePicker": { "selectImage": "Select a tracked object's thumbnail", + "unknownLabel": "Saved Trigger Image", "search": { "placeholder": "Search by label or sub label..." }, diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 72200ea1c..8bed74c01 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -883,7 +883,7 @@ "desc": "Semantic Search must be enabled to use Triggers." }, "management": { - "title": "Trigger Management", + "title": "Triggers", "desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify." }, "addTrigger": "Add Trigger", @@ -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/input/NameAndIdFields.tsx b/web/src/components/input/NameAndIdFields.tsx new file mode 100644 index 000000000..ad4ebcfcc --- /dev/null +++ b/web/src/components/input/NameAndIdFields.tsx @@ -0,0 +1,127 @@ +import { Control, FieldValues, Path, PathValue } from "react-hook-form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useState, useEffect } from "react"; +import { useFormContext } from "react-hook-form"; +import { generateFixedHash, isValidId } from "@/utils/stringUtil"; +import { useTranslation } from "react-i18next"; + +type NameAndIdFieldsProps = { + control: Control; + type?: string; + nameField: Path; + idField: Path; + nameLabel: string; + nameDescription?: string; + idLabel?: string; + idDescription?: string; + processId?: (name: string) => string; + placeholderName?: string; + placeholderId?: string; +}; + +export default function NameAndIdFields({ + control, + type, + nameField, + idField, + nameLabel, + nameDescription, + idLabel, + idDescription, + processId, + placeholderName, + placeholderId, +}: NameAndIdFieldsProps) { + const { t } = useTranslation(["common"]); + const { watch, setValue, trigger } = useFormContext(); + const [isIdVisible, setIsIdVisible] = useState(false); + + const defaultProcessId = (name: string) => { + const normalized = name.replace(/\s+/g, "_").toLowerCase(); + if (isValidId(normalized)) { + return normalized; + } else { + return generateFixedHash(name, type); + } + }; + + const effectiveProcessId = processId || defaultProcessId; + + useEffect(() => { + const subscription = watch((value, { name }) => { + if (name === nameField) { + const processedId = effectiveProcessId(value[nameField] || ""); + setValue(idField, processedId as PathValue>); + trigger(idField); + } + }); + return () => subscription.unsubscribe(); + }, [watch, setValue, trigger, nameField, idField, effectiveProcessId]); + + return ( + <> + ( + +
+ {nameLabel} + setIsIdVisible(!isIdVisible)} + > + {isIdVisible + ? t("label.hide", { item: idLabel ?? t("label.ID") }) + : t("label.show", { + item: idLabel ?? t("label.ID"), + })} + +
+ + + + {nameDescription && ( + {nameDescription} + )} + +
+ )} + /> + {isIdVisible && ( + ( + + {idLabel ?? t("label.ID")} + + + + + {idDescription ?? t("field.internalID")} + + + + )} + /> + )} + + ); +} diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx index 620d3fe62..efbb1dd7b 100644 --- a/web/src/components/overlay/CreateTriggerDialog.tsx +++ b/web/src/components/overlay/CreateTriggerDialog.tsx @@ -47,6 +47,7 @@ import { MobilePageHeader, MobilePageTitle, } from "../mobile/MobilePage"; +import NameAndIdFields from "@/components/input/NameAndIdFields"; type CreateTriggerDialogProps = { show: boolean; @@ -89,6 +90,19 @@ export default function CreateTriggerDialog({ 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 @@ -103,7 +117,15 @@ export default function CreateTriggerDialog({ !existingTriggerNames.includes(value) || value === trigger?.name, t("triggers.dialog.form.name.error.alreadyExists"), ), - friendly_name: z.string().optional(), + 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(["thumbnail", "description"]), data: z.string().min(1, t("triggers.dialog.form.content.error.required")), threshold: z @@ -138,7 +160,7 @@ export default function CreateTriggerDialog({ values.data, values.threshold, values.actions, - values.friendly_name ?? "", + values.friendly_name, ); } }; @@ -159,7 +181,7 @@ export default function CreateTriggerDialog({ { enabled: trigger.enabled, name: trigger.name, - friendly_name: trigger.friendly_name ?? "", + friendly_name: trigger.friendly_name ?? trigger.name, type: trigger.type, data: trigger.data, threshold: trigger.threshold, @@ -219,47 +241,14 @@ export default function CreateTriggerDialog({ onSubmit={form.handleSubmit(onSubmit)} className="space-y-5 pt-4" > - ( - - {t("triggers.dialog.form.name.title")} - - - - - - )} - /> - - ( - - - {t("triggers.dialog.form.friendly_name.title")} - - - - - - {t("triggers.dialog.form.friendly_name.description")} - - - - )} + nameField="friendly_name" + idField="name" + nameLabel={t("triggers.dialog.form.name.title")} + nameDescription={t("triggers.dialog.form.name.description")} + placeholderName={t("triggers.dialog.form.name.placeholder")} /> - - {t("triggers.dialog.form.content.imageDesc")} - ) : ( <> @@ -455,7 +441,7 @@ export default function CreateTriggerDialog({ > {isLoading ? (
- + {t("button.saving", { ns: "common" })}
) : ( diff --git a/web/src/components/overlay/ImagePicker.tsx b/web/src/components/overlay/ImagePicker.tsx index 408338d0d..eb8f7c09e 100644 --- a/web/src/components/overlay/ImagePicker.tsx +++ b/web/src/components/overlay/ImagePicker.tsx @@ -15,25 +15,33 @@ import { cn } from "@/lib/utils"; import { Event } from "@/types/event"; import { useApiHost } from "@/api"; import { isDesktop, isMobile } from "react-device-detect"; +import ActivityIndicator from "../indicators/activity-indicator"; type ImagePickerProps = { selectedImageId?: string; setSelectedImageId?: (id: string) => void; camera: string; + limit?: number; + direct?: boolean; + className?: string; }; export default function ImagePicker({ selectedImageId, setSelectedImageId, camera, + limit = 100, + direct = false, + className, }: ImagePickerProps) { - const { t } = useTranslation(["components/dialog"]); + const { t } = useTranslation(["components/dialog", "views/settings"]); const [open, setOpen] = useState(false); const containerRef = useRef(null); const [searchTerm, setSearchTerm] = useState(""); + const [loadedImages, setLoadedImages] = useState>(new Set()); const { data: events } = useSWR( - `events?camera=${camera}&limit=100`, + `events?camera=${camera}&limit=${limit}`, { revalidateOnFocus: false, }, @@ -62,12 +70,77 @@ export default function ImagePicker({ if (setSelectedImageId) { setSelectedImageId(id); } - setSearchTerm(""); - setOpen(false); + if (!direct) { + setOpen(false); + } }, - [setSelectedImageId], + [setSelectedImageId, direct], ); + const handleImageLoad = useCallback((imageId: string) => { + setLoadedImages((prev) => new Set(prev).add(imageId)); + }, []); + + const renderSearchInput = () => ( + { + setSearchTerm(e.target.value); + // Clear selected image when user starts typing + if (setSelectedImageId) { + setSelectedImageId(""); + } + }} + /> + ); + + const renderImageGrid = () => ( +
+ {images.length === 0 ? ( +
+ {t("imagePicker.noImages")} +
+ ) : ( + images.map((image) => ( +
+ {image.label} handleImageSelect(image.id)} + onLoad={() => handleImageLoad(image.id)} + loading="lazy" + /> + {!loadedImages.has(image.id) && ( +
+ +
+ )} +
+ )) + )} +
+ ); + + if (direct) { + return ( +
+ {renderSearchInput()} + {renderImageGrid()} +
+ ); + } + return (
-
- {selectedImage?.label +
+
+ {selectedImage?.label handleImageLoad(selectedImageId || "")} + loading="lazy" + /> + {selectedImageId && !loadedImages.has(selectedImageId) && ( +
+ +
+ )} +
- {selectedImage?.label || selectedImageId} + {selectedImage?.label || t("imagePicker.unknownLabel")} {selectedImage?.sub_label ? ` (${selectedImage.sub_label})` : ""} @@ -122,48 +204,23 @@ export default function ImagePicker({ -
+
{t("imagePicker.selectImage")} +
+ {t("triggers.dialog.form.content.imageDesc", { + ns: "views/settings", + })} +
- setSearchTerm(e.target.value)} - /> + {renderSearchInput()}
-
- {images.length === 0 ? ( -
- {t("imagePicker.noImages")} -
- ) : ( - images.map((image) => ( -
- {image.label} handleImageSelect(image.id)} - /> -
- )) - )} -
+ {renderImageGrid()}
diff --git a/web/src/components/trigger/TriggerWizardDialog.tsx b/web/src/components/trigger/TriggerWizardDialog.tsx new file mode 100644 index 000000000..976cf5f8a --- /dev/null +++ b/web/src/components/trigger/TriggerWizardDialog.tsx @@ -0,0 +1,255 @@ +import { useTranslation } from "react-i18next"; +import StepIndicator from "../indicators/StepIndicator"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { useReducer, useEffect } 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); + + useEffect(() => { + if (!open) { + dispatch({ type: "RESET" }); + } + }, [open]); + + // Reset wizard state when opening for a different trigger or when creating new + useEffect(() => { + if (open) { + dispatch({ type: "RESET" }); + } + }, [open, trigger]); + + 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 || "", + ); + } + // Remove handleClose() - let the parent component handle closing after save completes + }; + + const handleBack = () => { + dispatch({ type: "PREVIOUS_STEP" }); + }; + + const handleClose = () => { + dispatch({ type: "RESET" }); + onClose(); + }; + + return ( + { + if (!open && !isLoading) { + 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..265fba389 --- /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/input/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")} + + + ) : ( + <> + +