Trigger Wizard (#20691)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* add reusable component for combined name / internal name form field

* fix labels

* refactor utilities

* refactor image picker

* lazy loading

* don't clear text box

* trigger wizard

* image picker fixes

* use name and ID field in trigger edit dialog

* ensure wizard resets when reopening

* icon size tweak

* multiple triggers can trigger at once

* remove scrolling

* mobile tweaks

* remove duplicated component

* fix types

* use table on desktop and keep cards on mobile

* provide default
This commit is contained in:
Josh Hawkins 2025-10-27 14:58:31 -05:00 committed by GitHub
parent 710a77679b
commit 640007e5d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1441 additions and 278 deletions

View File

@ -93,7 +93,14 @@
} }
}, },
"label": { "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": { "button": {
"apply": "Apply", "apply": "Apply",

View File

@ -112,6 +112,7 @@
}, },
"imagePicker": { "imagePicker": {
"selectImage": "Select a tracked object's thumbnail", "selectImage": "Select a tracked object's thumbnail",
"unknownLabel": "Saved Trigger Image",
"search": { "search": {
"placeholder": "Search by label or sub label..." "placeholder": "Search by label or sub label..."
}, },

View File

@ -883,7 +883,7 @@
"desc": "Semantic Search must be enabled to use Triggers." "desc": "Semantic Search must be enabled to use Triggers."
}, },
"management": { "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." "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", "addTrigger": "Add Trigger",
@ -922,10 +922,11 @@
"form": { "form": {
"name": { "name": {
"title": "Name", "title": "Name",
"placeholder": "Enter trigger name", "placeholder": "Name this trigger",
"description": "Enter a unique name or description to identify this trigger",
"error": { "error": {
"minLength": "Name must be at least 2 characters long.", "minLength": "Field must be at least 2 characters long.",
"invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.", "invalidCharacters": "Field can only contain letters, numbers, underscores, and hyphens.",
"alreadyExists": "A trigger with this name already exists for this camera." "alreadyExists": "A trigger with this name already exists for this camera."
} }
}, },
@ -934,18 +935,15 @@
}, },
"type": { "type": {
"title": "Type", "title": "Type",
"placeholder": "Select trigger type" "placeholder": "Select trigger type",
}, "description": "Trigger when a similar tracked object description is detected",
"friendly_name": { "thumbnail": "Trigger when a similar tracked object thumbnail is detected"
"title": "Friendly Name",
"placeholder": "Name or describe this trigger",
"description": "An optional friendly name or descriptive text for this trigger."
}, },
"content": { "content": {
"title": "Content", "title": "Content",
"imagePlaceholder": "Select an image", "imagePlaceholder": "Select a thumbnail",
"textPlaceholder": "Enter text content", "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.", "textDesc": "Enter text to trigger this action when a similar tracked object description is detected.",
"error": { "error": {
"required": "Content is required." "required": "Content is required."
@ -953,6 +951,7 @@
}, },
"threshold": { "threshold": {
"title": "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": { "error": {
"min": "Threshold must be at least 0", "min": "Threshold must be at least 0",
"max": "Threshold must be at most 1" "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": { "toast": {
"success": { "success": {
"createTrigger": "Trigger {{name}} created successfully.", "createTrigger": "Trigger {{name}} created successfully.",

View File

@ -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<T extends FieldValues = FieldValues> = {
control: Control<T>;
type?: string;
nameField: Path<T>;
idField: Path<T>;
nameLabel: string;
nameDescription?: string;
idLabel?: string;
idDescription?: string;
processId?: (name: string) => string;
placeholderName?: string;
placeholderId?: string;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
control,
type,
nameField,
idField,
nameLabel,
nameDescription,
idLabel,
idDescription,
processId,
placeholderName,
placeholderId,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger } = useFormContext<T>();
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<T, Path<T>>);
trigger(idField);
}
});
return () => subscription.unsubscribe();
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
return (
<>
<FormField
control={control}
name={nameField}
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>{nameLabel}</FormLabel>
<span
className="cursor-pointer text-right text-xs text-muted-foreground"
onClick={() => setIsIdVisible(!isIdVisible)}
>
{isIdVisible
? t("label.hide", { item: idLabel ?? t("label.ID") })
: t("label.show", {
item: idLabel ?? t("label.ID"),
})}
</span>
</div>
<FormControl>
<Input
className="text-md"
placeholder={placeholderName}
{...field}
/>
</FormControl>
{nameDescription && (
<FormDescription>{nameDescription}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{isIdVisible && (
<FormField
control={control}
name={idField}
render={({ field }) => (
<FormItem>
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
<FormControl>
<Input
className="text-md"
placeholder={placeholderId}
{...field}
/>
</FormControl>
<FormDescription>
{idDescription ?? t("field.internalID")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
);
}

View File

@ -47,6 +47,7 @@ import {
MobilePageHeader, MobilePageHeader,
MobilePageTitle, MobilePageTitle,
} from "../mobile/MobilePage"; } from "../mobile/MobilePage";
import NameAndIdFields from "@/components/input/NameAndIdFields";
type CreateTriggerDialogProps = { type CreateTriggerDialogProps = {
show: boolean; show: boolean;
@ -89,6 +90,19 @@ export default function CreateTriggerDialog({
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers); return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
}, [config, selectedCamera]); }, [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({ const formSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
name: z name: z
@ -103,7 +117,15 @@ export default function CreateTriggerDialog({
!existingTriggerNames.includes(value) || value === trigger?.name, !existingTriggerNames.includes(value) || value === trigger?.name,
t("triggers.dialog.form.name.error.alreadyExists"), 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"]), type: z.enum(["thumbnail", "description"]),
data: z.string().min(1, t("triggers.dialog.form.content.error.required")), data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
threshold: z threshold: z
@ -138,7 +160,7 @@ export default function CreateTriggerDialog({
values.data, values.data,
values.threshold, values.threshold,
values.actions, values.actions,
values.friendly_name ?? "", values.friendly_name,
); );
} }
}; };
@ -159,7 +181,7 @@ export default function CreateTriggerDialog({
{ {
enabled: trigger.enabled, enabled: trigger.enabled,
name: trigger.name, name: trigger.name,
friendly_name: trigger.friendly_name ?? "", friendly_name: trigger.friendly_name ?? trigger.name,
type: trigger.type, type: trigger.type,
data: trigger.data, data: trigger.data,
threshold: trigger.threshold, threshold: trigger.threshold,
@ -219,47 +241,14 @@ export default function CreateTriggerDialog({
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4" className="space-y-5 pt-4"
> >
<FormField <NameAndIdFields
type="trigger"
control={form.control} control={form.control}
name="name" nameField="friendly_name"
render={({ field }) => ( idField="name"
<FormItem> nameLabel={t("triggers.dialog.form.name.title")}
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel> nameDescription={t("triggers.dialog.form.name.description")}
<FormControl> placeholderName={t("triggers.dialog.form.name.placeholder")}
<Input
placeholder={t("triggers.dialog.form.name.placeholder")}
className="h-10"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="friendly_name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("triggers.dialog.form.friendly_name.title")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"triggers.dialog.form.friendly_name.placeholder",
)}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.friendly_name.description")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/> />
<FormField <FormField
@ -335,9 +324,6 @@ export default function CreateTriggerDialog({
camera={selectedCamera} camera={selectedCamera}
/> />
</FormControl> </FormControl>
<FormDescription>
{t("triggers.dialog.form.content.imageDesc")}
</FormDescription>
</> </>
) : ( ) : (
<> <>
@ -455,7 +441,7 @@ export default function CreateTriggerDialog({
> >
{isLoading ? ( {isLoading ? (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<ActivityIndicator /> <ActivityIndicator className="size-5" />
<span>{t("button.saving", { ns: "common" })}</span> <span>{t("button.saving", { ns: "common" })}</span>
</div> </div>
) : ( ) : (

View File

@ -15,25 +15,33 @@ import { cn } from "@/lib/utils";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import ActivityIndicator from "../indicators/activity-indicator";
type ImagePickerProps = { type ImagePickerProps = {
selectedImageId?: string; selectedImageId?: string;
setSelectedImageId?: (id: string) => void; setSelectedImageId?: (id: string) => void;
camera: string; camera: string;
limit?: number;
direct?: boolean;
className?: string;
}; };
export default function ImagePicker({ export default function ImagePicker({
selectedImageId, selectedImageId,
setSelectedImageId, setSelectedImageId,
camera, camera,
limit = 100,
direct = false,
className,
}: ImagePickerProps) { }: ImagePickerProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog", "views/settings"]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
const { data: events } = useSWR<Event[]>( const { data: events } = useSWR<Event[]>(
`events?camera=${camera}&limit=100`, `events?camera=${camera}&limit=${limit}`,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
}, },
@ -62,12 +70,77 @@ export default function ImagePicker({
if (setSelectedImageId) { if (setSelectedImageId) {
setSelectedImageId(id); setSelectedImageId(id);
} }
setSearchTerm(""); if (!direct) {
setOpen(false); setOpen(false);
}
}, },
[setSelectedImageId], [setSelectedImageId, direct],
); );
const handleImageLoad = useCallback((imageId: string) => {
setLoadedImages((prev) => new Set(prev).add(imageId));
}, []);
const renderSearchInput = () => (
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
// Clear selected image when user starts typing
if (setSelectedImageId) {
setSelectedImageId("");
}
}}
/>
);
const renderImageGrid = () => (
<div className="grid grid-cols-2 gap-4 pr-1 sm:grid-cols-6">
{images.length === 0 ? (
<div className="col-span-2 text-center text-sm text-muted-foreground sm:col-span-6">
{t("imagePicker.noImages")}
</div>
) : (
images.map((image) => (
<div
key={image.id}
className={cn(
"relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 bg-background transition-all",
selectedImageId === image.id &&
"border-selected ring-2 ring-selected",
)}
>
<img
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
alt={image.label}
className="h-full w-full object-cover"
onClick={() => handleImageSelect(image.id)}
onLoad={() => handleImageLoad(image.id)}
loading="lazy"
/>
{!loadedImages.has(image.id) && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
</div>
))
)}
</div>
);
if (direct) {
return (
<div ref={containerRef} className={className}>
{renderSearchInput()}
{renderImageGrid()}
</div>
);
}
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
<Dialog <Dialog
@ -87,18 +160,27 @@ export default function ImagePicker({
) : ( ) : (
<div className="hover:cursor-pointer"> <div className="hover:cursor-pointer">
<div className="my-3 flex w-full flex-row items-center justify-between gap-2"> <div className="my-3 flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-4">
<img <div className="relative size-16">
src={ <img
selectedImage src={
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp` selectedImage
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp` ? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
} : `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
alt={selectedImage?.label || "Selected image"} }
className="h-8 w-8 rounded object-cover" alt={selectedImage?.label || "Selected image"}
/> className="size-16 rounded object-cover"
onLoad={() => handleImageLoad(selectedImageId || "")}
loading="lazy"
/>
{selectedImageId && !loadedImages.has(selectedImageId) && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
</div>
<div className="text-sm smart-capitalize"> <div className="text-sm smart-capitalize">
{selectedImage?.label || selectedImageId} {selectedImage?.label || t("imagePicker.unknownLabel")}
{selectedImage?.sub_label {selectedImage?.sub_label
? ` (${selectedImage.sub_label})` ? ` (${selectedImage.sub_label})`
: ""} : ""}
@ -122,48 +204,23 @@ export default function ImagePicker({
<DialogContent <DialogContent
className={cn( className={cn(
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl", isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-[70%]",
isMobile && "px-4", isMobile && "scrollbar-container max-h-[90%] overflow-y-auto px-4",
className,
)} )}
> >
<div className="mb-3 flex flex-row items-center justify-between"> <div className="mb-3 flex flex-col items-start justify-start">
<Heading as="h4">{t("imagePicker.selectImage")}</Heading> <Heading as="h4">{t("imagePicker.selectImage")}</Heading>
<div className="text-sm text-muted-foreground">
{t("triggers.dialog.form.content.imageDesc", {
ns: "views/settings",
})}
</div>
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
</div> </div>
<Input {renderSearchInput()}
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="scrollbar-container flex h-full flex-col overflow-y-auto"> <div className="scrollbar-container flex h-full flex-col overflow-y-auto">
<div className="grid grid-cols-3 gap-2 pr-1"> {renderImageGrid()}
{images.length === 0 ? (
<div className="col-span-3 text-center text-sm text-muted-foreground">
{t("imagePicker.noImages")}
</div>
) : (
images.map((image) => (
<div
key={image.id}
className={cn(
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
selectedImageId === image.id
? "bg-selected text-white"
: "hover:bg-secondary-foreground",
)}
>
<img
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
alt={image.label}
className="rounded object-cover"
onClick={() => handleImageSelect(image.id)}
/>
</div>
))
)}
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -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<WizardState> }
| { 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 (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open && !isLoading) {
handleClose();
}
}}
>
<DialogContent
className={cn(
"",
isDesktop &&
wizardState.currentStep == 1 &&
wizardState.step1Data?.type == "thumbnail"
? "max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]"
: "max-h-[90%] overflow-y-auto xl:max-h-[80%]",
)}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<StepIndicator
steps={TRIGGER_STEPS}
currentStep={wizardState.currentStep}
variant="dots"
className="mb-4 justify-start"
/>
<DialogHeader>
<DialogTitle>{t("triggers.wizard.title")}</DialogTitle>
{wizardState.currentStep === 0 && (
<DialogDescription>
{t("triggers.wizard.step1.description")}
</DialogDescription>
)}
{wizardState.currentStep === 1 && (
<DialogDescription>
{t("triggers.wizard.step2.description")}
</DialogDescription>
)}
{wizardState.currentStep === 2 && (
<DialogDescription>
{t("triggers.wizard.step3.description")}
</DialogDescription>
)}
</DialogHeader>
<div className="pb-4">
{wizardState.currentStep === 0 && (
<Step1NameAndType
initialData={wizardState.step1Data}
trigger={trigger}
selectedCamera={selectedCamera}
onNext={handleStep1Next}
onCancel={handleClose}
/>
)}
{wizardState.currentStep === 1 && wizardState.step1Data && (
<Step2ConfigureData
initialData={wizardState.step2Data}
triggerType={wizardState.step1Data.type}
selectedCamera={selectedCamera}
onNext={handleStep2Next}
onBack={handleBack}
/>
)}
{wizardState.currentStep === 2 &&
wizardState.step1Data &&
wizardState.step2Data && (
<Step3ThresholdAndActions
initialData={wizardState.step3Data}
trigger={trigger}
onNext={handleStep3Next}
onBack={handleBack}
isLoading={isLoading}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<FrigateConfig>("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<z.infer<typeof formSchema>>({
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<typeof formSchema>) => {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<NameAndIdFields
type="trigger"
control={form.control}
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")}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.type.title")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-10">
<SelectValue
placeholder={t("triggers.dialog.form.type.placeholder")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="description">
{t("triggers.type.description")}
</SelectItem>
<SelectItem value="thumbnail">
{t("triggers.type.thumbnail")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("triggers.dialog.form.type.description")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
data: initialData?.data ?? "",
},
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
onNext({
data: values.data,
});
};
useEffect(() => {
if (initialData) {
form.reset(initialData);
}
}, [initialData, form]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormItem>
{triggerType === "thumbnail" ? (
<>
<FormLabel className="font-normal">
{t("triggers.dialog.form.content.imagePlaceholder")}
</FormLabel>
<FormControl>
<ImagePicker
selectedImageId={field.value}
setSelectedImageId={field.onChange}
camera={selectedCamera}
direct
className="max-h-[50dvh] overflow-y-auto rounded-lg border p-4"
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.imageDesc")}
</FormDescription>
</>
) : (
<>
<FormControl>
<Textarea
placeholder={t(
"triggers.dialog.form.content.textPlaceholder",
)}
{...field}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.textDesc")}
</FormDescription>
</>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
{t("button.back", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={!form.formState.isValid}
className="flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,194 @@
import { useEffect, useCallback } 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 { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Trigger, TriggerAction } from "@/types/trigger";
import ActivityIndicator from "@/components/indicators/activity-indicator";
export type Step3FormData = {
threshold: number;
actions: TriggerAction[];
};
type Step3ThresholdAndActionsProps = {
initialData?: Step3FormData;
trigger?: Trigger | null;
onNext: (data: Step3FormData) => void;
onBack: () => void;
isLoading?: boolean;
};
export default function Step3ThresholdAndActions({
initialData,
trigger,
onNext,
onBack,
isLoading = false,
}: Step3ThresholdAndActionsProps) {
const { t } = useTranslation("views/settings");
const formSchema = z.object({
threshold: z
.number()
.min(0, t("triggers.dialog.form.threshold.error.min"))
.max(1, t("triggers.dialog.form.threshold.error.max")),
actions: z.array(z.enum(["notification"])),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
threshold: initialData?.threshold ?? trigger?.threshold ?? 0.5,
actions:
initialData?.actions ?? (trigger?.actions as TriggerAction[]) ?? [],
},
});
const onSubmit = useCallback(
(values: z.infer<typeof formSchema>) => {
onNext({
threshold: values.threshold,
actions: values.actions,
});
},
[onNext],
);
const handleSave = useCallback(() => {
const formData = form.getValues();
// Basic validation
if (formData.threshold < 0 || formData.threshold > 1) {
return;
}
onNext(formData);
}, [form, onNext]);
useEffect(() => {
if (initialData) {
form.reset(initialData);
} else if (trigger) {
form.reset({
threshold: trigger.threshold,
actions: trigger.actions,
});
}
}, [initialData, trigger, form]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="threshold"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.threshold.title")}</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min="0"
max="1"
placeholder="0.50"
className="h-10"
{...field}
onChange={(e) => {
const value = parseFloat(e.target.value);
field.onChange(isNaN(value) ? 0 : value);
}}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.threshold.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="actions"
render={() => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.actions.title")}</FormLabel>
<div className="space-y-2">
{["notification"].map((action) => (
<div key={action} className="flex items-center space-x-2">
<FormControl>
<Checkbox
checked={form
.watch("actions")
.includes(action as TriggerAction)}
onCheckedChange={(checked) => {
const currentActions = form.getValues("actions");
if (checked) {
form.setValue("actions", [
...currentActions,
action as TriggerAction,
]);
} else {
form.setValue(
"actions",
currentActions.filter((a) => a !== action),
);
}
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{t(`triggers.actions.${action}`)}
</FormLabel>
</div>
))}
</div>
<FormDescription>
{t("triggers.dialog.form.actions.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isLoading}
className="flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-5" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("triggers.dialog.form.save", {
defaultValue: "Save Trigger",
})}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -1,39 +1,4 @@
// ==================== Camera Name Processing ==================== import { generateFixedHash, isValidId } from "./stringUtil";
/**
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
* Works safely with Unicode input while outputting Latin-only identifiers.
*
* @param name - The original camera name/display name
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
*/
export function generateFixedHash(name: string): string {
// Safely encode Unicode as UTF-8 bytes
const utf8Bytes = new TextEncoder().encode(name);
// Convert to base64 manually
let binary = "";
for (const byte of utf8Bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
// Strip out non-alphanumeric characters and truncate
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `cam_${cleanHash.toLowerCase()}`;
}
/**
* Checks if a string is a valid camera name identifier.
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
*
* @param name - The camera name to validate
* @returns True if the name is valid, false otherwise
*/
export function isValidCameraName(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/** /**
* Processes a user-entered camera name and returns both the final camera name * Processes a user-entered camera name and returns both the final camera name
@ -48,7 +13,7 @@ export function processCameraName(userInput: string): {
} { } {
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase(); const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
if (isValidCameraName(normalizedInput)) { if (isValidId(normalizedInput)) {
return { return {
finalCameraName: normalizedInput, finalCameraName: normalizedInput,
friendlyName: userInput.includes(" ") ? userInput : undefined, friendlyName: userInput.includes(" ") ? userInput : undefined,
@ -56,7 +21,7 @@ export function processCameraName(userInput: string): {
} }
return { return {
finalCameraName: generateFixedHash(userInput), finalCameraName: generateFixedHash(userInput, "cam"),
friendlyName: userInput, friendlyName: userInput,
}; };
} }

View File

@ -9,3 +9,39 @@ export const capitalizeAll = (text: string): string => {
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" "); .join(" ");
}; };
/**
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
* Works safely with Unicode input while outputting Latin-only identifiers.
*
* @param name - The original camera name/display name
* @param prefix - Optional prefix for the identifier
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
*/
export function generateFixedHash(name: string, prefix: string = "id"): string {
// Safely encode Unicode as UTF-8 bytes
const utf8Bytes = new TextEncoder().encode(name);
// Convert to base64 manually
let binary = "";
for (const byte of utf8Bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
// Strip out non-alphanumeric characters and truncate
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `${prefix}_${cleanHash.toLowerCase()}`;
}
/**
* Checks if a string is a valid camera name identifier.
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
*
* @param name - The camera name to validate
* @returns True if the name is valid, false otherwise
*/
export function isValidId(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}

View File

@ -13,14 +13,24 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
LuPlus, LuPlus,
LuTrash, LuTrash,
LuPencil, LuPencil,
LuSearch, LuSearch,
LuExternalLink, LuExternalLink,
LuCircle,
} from "react-icons/lu"; } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import TriggerWizardDialog from "@/components/trigger/TriggerWizardDialog";
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog"; import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog"; import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -90,7 +100,7 @@ export default function TriggerView({
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null); const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
const [triggeredTrigger, setTriggeredTrigger] = useState<string>(); const [triggeredTrigger, setTriggeredTrigger] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const cameraName = useCameraFriendlyName(selectedCamera); const cameraName = useCameraFriendlyName(selectedCamera);
@ -127,28 +137,22 @@ export default function TriggerView({
mutate(); mutate();
setTriggeredTrigger(triggers_status_ws.name); setTriggeredTrigger((prev) => {
const target = document.querySelector( const current = prev || [];
`#trigger-${triggers_status_ws.name}`, if (!current.includes(triggers_status_ws.name)) {
); const newTriggers = [...current, triggers_status_ws.name];
if (target) { return newTriggers;
target.scrollIntoView({
block: "center",
behavior: "smooth",
inline: "nearest",
});
const ring = target.querySelector(".trigger-ring");
if (ring) {
ring.classList.add(`outline-selected`);
ring.classList.remove("outline-transparent");
const timeout = setTimeout(() => {
ring.classList.remove(`outline-selected`);
ring.classList.add("outline-transparent");
}, 3000);
return () => clearTimeout(timeout);
} }
} return current;
});
const timeout = setTimeout(() => {
setTriggeredTrigger((prev) =>
(prev || []).filter((name) => name !== triggers_status_ws.name),
);
}, 3000);
return () => clearTimeout(timeout);
}, [triggers_status_ws, selectedCamera, mutate]); }, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => { useEffect(() => {
@ -228,6 +232,7 @@ export default function TriggerView({
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
setShowCreate(false); setShowCreate(false);
setSelectedTrigger(null);
}); });
}, },
[t, updateConfig, selectedCamera, setUnsavedChanges], [t, updateConfig, selectedCamera, setUnsavedChanges],
@ -313,7 +318,6 @@ export default function TriggerView({
// Regular update without rename // Regular update without rename
saveToConfig(trigger, true); saveToConfig(trigger, true);
} }
setSelectedTrigger(null);
}, },
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges], [t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
); );
@ -386,6 +390,7 @@ export default function TriggerView({
setShowCreate(false); setShowCreate(false);
setShowDelete(false); setShowDelete(false);
setUnsavedChanges(false); setUnsavedChanges(false);
setTriggeredTrigger([]);
} }
}, [selectedCamera, setUnsavedChanges]); }, [selectedCamera, setUnsavedChanges]);
@ -480,72 +485,69 @@ export default function TriggerView({
</Button> </Button>
</div> </div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt"> <div className="flex flex-1 flex-col gap-2 md:hidden">
<div className="h-full overflow-auto p-0"> {triggers.length === 0 ? (
{triggers.length === 0 ? ( <div className="flex h-24 items-center justify-center rounded-lg border border-border bg-background_alt">
<div className="flex h-24 items-center justify-center"> <p className="text-center text-muted-foreground">
<p className="text-center text-muted-foreground"> {t("triggers.table.noTriggers")}
{t("triggers.table.noTriggers")} </p>
</p> </div>
</div> ) : (
) : ( triggers.map((trigger) => (
<div className="space-y-2"> <div
{triggers.map((trigger) => ( key={trigger.name}
<div id={`trigger-${trigger.name}`}
key={trigger.name} className="rounded-lg border border-border bg-background p-4"
id={`trigger-${trigger.name}`} >
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all" <div className="flex items-start justify-between gap-3">
> <div className="flex items-start gap-3">
<div <div className="mt-1">
className={cn( <LuCircle
"trigger-ring pointer-events-none absolute inset-0 z-10 size-full rounded-md outline outline-[3px] -outline-offset-[2.8px] duration-500",
triggeredTrigger === trigger.name
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="min-w-0 flex-1">
<h3
className={cn( className={cn(
"truncate text-lg font-medium", "size-3 duration-500",
triggeredTrigger.includes(trigger.name)
? "fill-selected text-selected"
: "fill-muted text-muted dark:fill-secondary-highlight dark:text-secondary-highlight",
)}
/>
</div>
<div className="flex-1">
<div
className={cn(
"font-medium",
!trigger.enabled && "opacity-60", !trigger.enabled && "opacity-60",
)} )}
> >
{trigger.friendly_name || trigger.name} {trigger.friendly_name || trigger.name}
</h3> </div>
<div <div className="mt-2 flex flex-col gap-2">
className={cn( <Badge
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3", variant={
!trigger.enabled && "opacity-60", trigger.type === "thumbnail"
)} ? "default"
> : "outline"
<div> }
<Badge className={cn(
variant={ "w-fit",
trigger.type === "thumbnail" trigger.type === "thumbnail"
? "default" ? "bg-primary/20 text-primary hover:bg-primary/30"
: "outline" : "",
} !trigger.enabled && "opacity-60",
className={ )}
trigger.type === "thumbnail" >
? "bg-primary/20 text-primary hover:bg-primary/30" {t(`triggers.type.${trigger.type}`)}
: "" </Badge>
}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
</div>
<Link <Link
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`} to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
className={cn( className={cn(
"text-sm", "flex items-center gap-1.5 text-xs text-muted-foreground",
!trigger_status?.triggers[trigger.name] !trigger_status?.triggers[trigger.name]
?.triggering_event_id && ?.triggering_event_id &&
"pointer-events-none", "pointer-events-none",
!trigger.enabled && "opacity-60",
)} )}
> >
<div className="flex flex-row items-center"> <span>
{t("triggers.table.lastTriggered")}:{" "} {t("triggers.table.lastTriggered")}:{" "}
{trigger_status && {trigger_status &&
trigger_status.triggers[trigger.name] trigger_status.triggers[trigger.name]
@ -574,9 +576,173 @@ export default function TriggerView({
}, },
) )
: "Never"} : "Never"}
</span>
{trigger_status?.triggers[trigger.name]
?.triggering_event_id && (
<LuSearch className="size-3" />
)}
</Link>
</div>
</div>
</div>
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedTrigger(trigger);
setShowCreate(true);
}}
disabled={isLoading}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.edit")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedTrigger(trigger);
setShowDelete(true);
}}
disabled={isLoading}
>
<LuTrash className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.deleteTrigger")}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</div>
))
)}
</div>
{/* Desktop Table View */}
<div className="scrollbar-container hidden flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3 md:block">
<div className="h-full overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-4"></TableHead>
<TableHead>{t("name", { ns: "common" })}</TableHead>
<TableHead>{t("triggers.table.type")}</TableHead>
<TableHead>
{t("triggers.table.lastTriggered")}
</TableHead>
<TableHead className="text-right">
{t("triggers.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{triggers.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
{t("triggers.table.noTriggers")}
</TableCell>
</TableRow>
) : (
triggers.map((trigger) => (
<TableRow
key={trigger.name}
id={`trigger-${trigger.name}`}
className="group"
>
<TableCell>
<LuCircle
className={cn(
"size-3 duration-500",
triggeredTrigger.includes(trigger.name)
? "fill-selected text-selected"
: "fill-muted text-muted dark:fill-secondary-highlight dark:text-secondary-highlight",
)}
/>
</TableCell>
<TableCell className="font-medium">
<div
className={cn(!trigger.enabled && "opacity-60")}
>
{trigger.friendly_name || trigger.name}
</div>
</TableCell>
<TableCell>
<Badge
variant={
trigger.type === "thumbnail"
? "default"
: "outline"
}
className={cn(
trigger.type === "thumbnail"
? "bg-primary/20 text-primary hover:bg-primary/30"
: "",
!trigger.enabled && "opacity-60",
)}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
</TableCell>
<TableCell>
<Link
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
className={cn(
"flex items-center gap-1.5 text-sm",
!trigger_status?.triggers[trigger.name]
?.triggering_event_id &&
"pointer-events-none",
!trigger.enabled && "opacity-60",
)}
>
<span>
{trigger_status &&
trigger_status.triggers[trigger.name]
?.last_triggered
? formatUnixTimestampToDateTime(
trigger_status.triggers[trigger.name]
?.last_triggered,
{
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t(
"time.formattedTimestamp2.24hour",
{
ns: "common",
},
)
: t(
"time.formattedTimestamp2.12hour",
{
ns: "common",
},
),
time_style: "medium",
date_style: "medium",
},
)
: "Never"}
</span>
{trigger_status?.triggers[trigger.name]
?.triggering_event_id && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<LuSearch className="ml-2 size-3.5" /> <LuSearch className="size-3.5" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("details.item.button.viewInExplore", { {t("details.item.button.viewInExplore", {
@ -584,65 +750,85 @@ export default function TriggerView({
})} })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> )}
</Link> </Link>
</div> </TableCell>
</div> <TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center gap-2"> <div className="flex items-center justify-end gap-2">
<TooltipProvider> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" className="h-8 px-2"
className="h-8 w-8 p-0" onClick={() => {
onClick={() => { setSelectedTrigger(trigger);
setSelectedTrigger(trigger); setShowCreate(true);
setShowCreate(true); }}
}} disabled={isLoading}
disabled={isLoading} >
> <LuPencil className="size-3.5" />
<LuPencil className="size-3.5" /> <span className="ml-1.5 hidden sm:inline-block">
</Button> {t("triggers.table.edit")}
</TooltipTrigger> </span>
<TooltipContent> </Button>
<p>{t("triggers.table.edit")}</p> </TooltipTrigger>
</TooltipContent> <TooltipContent>
</Tooltip> <p>{t("triggers.table.edit")}</p>
<Tooltip> </TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<Button <Tooltip>
size="sm" <TooltipTrigger asChild>
variant="destructive" <Button
className="h-8 w-8 p-0 text-white" size="sm"
onClick={() => { variant="destructive"
setSelectedTrigger(trigger); className="h-8 px-2"
setShowDelete(true); onClick={() => {
}} setSelectedTrigger(trigger);
disabled={isLoading} setShowDelete(true);
> }}
<LuTrash className="size-3.5" /> disabled={isLoading}
</Button> >
</TooltipTrigger> <LuTrash className="size-3.5" />
<TooltipContent> <span className="ml-1.5 hidden sm:inline-block">
<p>{t("triggers.table.deleteTrigger")}</p> {t("triggers.table.deleteTrigger")}
</TooltipContent> </span>
</Tooltip> </Button>
</TooltipProvider> </TooltipTrigger>
</div> <TooltipContent>
</div> <p>{t("triggers.table.deleteTrigger")}</p>
))} </TooltipContent>
</div> </Tooltip>
)} </div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div> </div>
</div> </div>
</div> </div>
</> </>
)} )}
</div> </div>
<TriggerWizardDialog
open={showCreate && (!selectedTrigger || selectedTrigger.name === "")}
onClose={() => {
setShowCreate(false);
setSelectedTrigger(null);
setUnsavedChanges(false);
}}
selectedCamera={selectedCamera}
trigger={null}
onCreate={onCreate}
onEdit={onEdit}
isLoading={isLoading}
/>
<CreateTriggerDialog <CreateTriggerDialog
show={showCreate} show={showCreate && !!selectedTrigger && selectedTrigger.name !== ""}
trigger={selectedTrigger} trigger={selectedTrigger}
selectedCamera={selectedCamera} selectedCamera={selectedCamera}
isLoading={isLoading} isLoading={isLoading}