Compare commits

...

2 Commits

Author SHA1 Message Date
Josh Hawkins
640007e5d3
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
2025-10-27 14:58:31 -05:00
Nicolas Mowen
710a77679b
Improve default genai review prompt structure (#20690) 2025-10-27 11:34:39 -05:00
16 changed files with 1480 additions and 294 deletions

View File

@ -105,13 +105,34 @@ class GenAIReviewConfig(FrigateBaseModel):
default=None,
)
activity_context_prompt: str = Field(
default="""- **Zone context is critical**: Private enclosed spaces (back yards, back decks, fenced areas, inside garages) are resident territory where brief transient activity, routine tasks, and pet care are expected and normal. Front yards, driveways, and porches are semi-public but still resident spaces where deliveries, parking, and coming/going are routine. Consider whether the zone and activity align with normal residential use.
- **Person + Pet = Normal Activity**: When both "Person" and "Dog" (or "Cat") are detected together in residential zones, this is routine pet care activity (walking, letting out, playing, supervising). Assign Level 0 unless there are OTHER strong suspicious behaviors present (like testing doors, taking items, etc.). A person with their pet in a residential zone is baseline normal activity.
- Brief appearances in private zones (back yards, garages) are normal residential patterns.
- Normal residential activity includes: residents, family members, guests, deliveries, services, maintenance workers, routine property use (parking, unloading, mail pickup, trash removal).
- Brief movement with legitimate items (bags, packages, tools, equipment) in appropriate zones is routine.
""",
title="Custom activity context prompt defining normal activity patterns for this property.",
default="""### Normal Activity Indicators (Level 0)
- Known/verified people in any zone
- People with pets in residential areas
- Deliveries: carrying packages to porches/doors, placing packages, leaving
- Access to private areas: entering back yards, garages, or homes
- Brief movement through semi-public areas (driveways, front yards) with clear purpose (carrying items, going to/from vehicles)
- Activity on public areas only (sidewalks, streets) without entering property
- Services/maintenance with visible indicators (tools, uniforms, work vehicles)
### Suspicious Activity Indicators (Level 1)
- Testing doors or windows on vehicles or buildings
- Standing near vehicles or in private zones without clear purpose or direct movement to destination
- Taking items from property (packages, objects from porches/driveways)
- Accessing areas at unusual hours without visible legitimate indicators (items, tools, purpose)
- Climbing or jumping fences/barriers
- Attempting to conceal actions or items
- Person in semi-public areas (driveways, front yards) at unusual hours without clear purpose
### Critical Threat Indicators (Level 2)
- Holding break-in tools (crowbars, pry bars, bolt cutters)
- Weapons visible (guns, knives, bats used aggressively)
- Forced entry in progress
- Physical aggression or violence
- Active property damage or theft
### Assessment Guidance
These patterns are guidance, not absolute rules. Context matters: time of day, visible items/tools, and apparent purpose help distinguish normal from suspicious. Not all cameras show full entry/exit paths - focus on observable behavior in frame. Use judgment based on the complete picture.""",
title="Custom activity context prompt defining normal and suspicious activity patterns for this property.",
)

View File

@ -29,6 +29,9 @@ from ..types import DataProcessorMetrics
logger = logging.getLogger(__name__)
RECORDING_BUFFER_START_SECONDS = 5
RECORDING_BUFFER_END_SECONDS = 10
class ReviewDescriptionProcessor(PostProcessorApi):
def __init__(
@ -111,8 +114,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
if image_source == ImageSourceEnum.recordings:
thumbs = self.get_recording_frames(
camera,
final_data["start_time"],
final_data["end_time"],
final_data["start_time"] - RECORDING_BUFFER_START_SECONDS,
final_data["end_time"] + RECORDING_BUFFER_END_SECONDS,
height=480, # Use 480p for good balance between quality and token usage
)

View File

@ -80,10 +80,10 @@ Your task is to analyze the sequence of images ({len(thumbnails)} total) taken i
Your task is to provide a clear, accurate description of the scene that:
1. States exactly what is happening based on observable actions and movements.
2. Evaluates whether the observable evidence suggests normal activity for this property or genuine security concerns.
2. Evaluates the activity against the Normal and Suspicious Activity Indicators above.
3. Assigns a potential_threat_level based on the definitions below, applying them consistently.
**IMPORTANT: Start by checking if the activity matches the normal patterns above. If it does, assign Level 0. Only consider higher threat levels if the activity clearly deviates from normal patterns or shows genuine security concerns.**
**Use the activity patterns above as guidance to calibrate your assessment. Match the activity against both normal and suspicious indicators, then use your judgment based on the complete context.**
## Analysis Guidelines
@ -94,8 +94,7 @@ When forming your description:
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
- **Use the actual timestamp provided in "Activity started at"** below for time of day contextdo not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
- Identify patterns that suggest genuine security concerns: testing doors/windows on vehicles or buildings, accessing unauthorized areas, attempting to conceal actions, extended loitering without apparent purpose, taking items, behavior that clearly doesn't align with the zone context and detected objects.
- **Weigh all evidence holistically**: Start by checking if the activity matches the normal patterns above. If it does, assign Level 0. Only consider Level 1 if the activity clearly deviates from normal patterns or shows genuine security concerns that warrant attention.
- **Weigh all evidence holistically**: Match the activity against both the normal and suspicious patterns above, then evaluate based on the complete context (zone, objects, time, actions). Activities matching normal patterns should be Level 0. Activities matching suspicious indicators should be Level 1. Use your judgment for edge cases.
## Response Format
@ -108,9 +107,9 @@ Your response MUST be a flat JSON object with:
## Threat Level Definitions
- 0 **Normal activity (DEFAULT)**: What you observe matches the normal activity patterns above or is consistent with expected activity for this property type. The observable evidenceconsidering zone context, detected objects, and timing togethersupports a benign explanation. **Use this level for routine activities even if minor ambiguous elements exist.**
- 1 **Potentially suspicious**: Observable behavior raises genuine security concerns that warrant human review. The evidence doesn't support a routine explanation and clearly deviates from the normal patterns above. Examples: testing doors/windows on vehicles or structures, accessing areas that don't align with the activity, taking items that likely don't belong to them, behavior clearly inconsistent with the zone and context, or activity that lacks any visible legitimate indicators. **Only use this level when the activity clearly doesn't match normal patterns.**
- 2 **Immediate threat**: Clear evidence of forced entry, break-in, vandalism, aggression, weapons, theft in progress, or active property damage.
- 0 **Normal activity**: The observable activity aligns with the Normal Activity Patterns above. The evidenceconsidering zone, objects, time, and actions togethersupports a benign explanation. **Use this level for routine activities even if minor ambiguous elements exist.**
- 1 **Potentially suspicious**: The observable activity aligns with the Suspicious Activity Indicators above, or shows behavior that raises genuine security concerns. The activity warrants human review. **Use this level when the evidence suggests concerning behavior, even if not an immediate threat.**
- 2 **Immediate threat**: Clear evidence of active criminal activity, forced entry, break-in, vandalism, aggression, weapons, theft in progress, or property damage.
## Sequence Details

View File

@ -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",

View File

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

View File

@ -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.",

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,
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"
>
<FormField
<NameAndIdFields
type="trigger"
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
<FormControl>
<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>
)}
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
@ -335,9 +324,6 @@ export default function CreateTriggerDialog({
camera={selectedCamera}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.content.imageDesc")}
</FormDescription>
</>
) : (
<>
@ -455,7 +441,7 @@ export default function CreateTriggerDialog({
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<ActivityIndicator className="size-5" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (

View File

@ -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<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
const { data: events } = useSWR<Event[]>(
`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 = () => (
<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 (
<div ref={containerRef}>
<Dialog
@ -87,18 +160,27 @@ export default function ImagePicker({
) : (
<div className="hover:cursor-pointer">
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-2">
<img
src={
selectedImage
? `${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"
/>
<div className="flex flex-row items-center gap-4">
<div className="relative size-16">
<img
src={
selectedImage
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
}
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">
{selectedImage?.label || selectedImageId}
{selectedImage?.label || t("imagePicker.unknownLabel")}
{selectedImage?.sub_label
? ` (${selectedImage.sub_label})`
: ""}
@ -122,48 +204,23 @@ export default function ImagePicker({
<DialogContent
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl",
isMobile && "px-4",
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-[70%]",
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>
<div className="text-sm text-muted-foreground">
{t("triggers.dialog.form.content.imageDesc", {
ns: "views/settings",
})}
</div>
<span tabIndex={0} className="sr-only" />
</div>
<Input
type="text"
placeholder={t("imagePicker.search.placeholder")}
className="text-md mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{renderSearchInput()}
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
<div className="grid grid-cols-3 gap-2 pr-1">
{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>
{renderImageGrid()}
</div>
</DialogContent>
</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 ====================
/**
* 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);
}
import { generateFixedHash, isValidId } from "./stringUtil";
/**
* 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();
if (isValidCameraName(normalizedInput)) {
if (isValidId(normalizedInput)) {
return {
finalCameraName: normalizedInput,
friendlyName: userInput.includes(" ") ? userInput : undefined,
@ -56,7 +21,7 @@ export function processCameraName(userInput: string): {
}
return {
finalCameraName: generateFixedHash(userInput),
finalCameraName: generateFixedHash(userInput, "cam"),
friendlyName: userInput,
};
}

View File

@ -9,3 +9,39 @@ export const capitalizeAll = (text: string): string => {
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.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,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
LuPlus,
LuTrash,
LuPencil,
LuSearch,
LuExternalLink,
LuCircle,
} from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TriggerWizardDialog from "@/components/trigger/TriggerWizardDialog";
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
import { FrigateConfig } from "@/types/frigateConfig";
@ -90,7 +100,7 @@ export default function TriggerView({
const [showCreate, setShowCreate] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
const [triggeredTrigger, setTriggeredTrigger] = useState<string>();
const [triggeredTrigger, setTriggeredTrigger] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const cameraName = useCameraFriendlyName(selectedCamera);
@ -127,28 +137,22 @@ export default function TriggerView({
mutate();
setTriggeredTrigger(triggers_status_ws.name);
const target = document.querySelector(
`#trigger-${triggers_status_ws.name}`,
);
if (target) {
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);
setTriggeredTrigger((prev) => {
const current = prev || [];
if (!current.includes(triggers_status_ws.name)) {
const newTriggers = [...current, triggers_status_ws.name];
return newTriggers;
}
}
return current;
});
const timeout = setTimeout(() => {
setTriggeredTrigger((prev) =>
(prev || []).filter((name) => name !== triggers_status_ws.name),
);
}, 3000);
return () => clearTimeout(timeout);
}, [triggers_status_ws, selectedCamera, mutate]);
useEffect(() => {
@ -228,6 +232,7 @@ export default function TriggerView({
.finally(() => {
setIsLoading(false);
setShowCreate(false);
setSelectedTrigger(null);
});
},
[t, updateConfig, selectedCamera, setUnsavedChanges],
@ -313,7 +318,6 @@ export default function TriggerView({
// Regular update without rename
saveToConfig(trigger, true);
}
setSelectedTrigger(null);
},
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
);
@ -386,6 +390,7 @@ export default function TriggerView({
setShowCreate(false);
setShowDelete(false);
setUnsavedChanges(false);
setTriggeredTrigger([]);
}
}, [selectedCamera, setUnsavedChanges]);
@ -480,72 +485,69 @@ export default function TriggerView({
</Button>
</div>
<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="h-full overflow-auto p-0">
{triggers.length === 0 ? (
<div className="flex h-24 items-center justify-center">
<p className="text-center text-muted-foreground">
{t("triggers.table.noTriggers")}
</p>
</div>
) : (
<div className="space-y-2">
{triggers.map((trigger) => (
<div
key={trigger.name}
id={`trigger-${trigger.name}`}
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all"
>
<div
className={cn(
"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
<div className="flex flex-1 flex-col gap-2 md:hidden">
{triggers.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border border-border bg-background_alt">
<p className="text-center text-muted-foreground">
{t("triggers.table.noTriggers")}
</p>
</div>
) : (
triggers.map((trigger) => (
<div
key={trigger.name}
id={`trigger-${trigger.name}`}
className="rounded-lg border border-border bg-background p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="mt-1">
<LuCircle
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.friendly_name || trigger.name}
</h3>
<div
className={cn(
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3",
!trigger.enabled && "opacity-60",
)}
>
<div>
<Badge
variant={
trigger.type === "thumbnail"
? "default"
: "outline"
}
className={
trigger.type === "thumbnail"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
</div>
</div>
<div className="mt-2 flex flex-col gap-2">
<Badge
variant={
trigger.type === "thumbnail"
? "default"
: "outline"
}
className={cn(
"w-fit",
trigger.type === "thumbnail"
? "bg-primary/20 text-primary hover:bg-primary/30"
: "",
!trigger.enabled && "opacity-60",
)}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
<Link
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
className={cn(
"text-sm",
"flex items-center gap-1.5 text-xs text-muted-foreground",
!trigger_status?.triggers[trigger.name]
?.triggering_event_id &&
"pointer-events-none",
!trigger.enabled && "opacity-60",
)}
>
<div className="flex flex-row items-center">
<span>
{t("triggers.table.lastTriggered")}:{" "}
{trigger_status &&
trigger_status.triggers[trigger.name]
@ -574,9 +576,173 @@ export default function TriggerView({
},
)
: "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>
<TooltipTrigger>
<LuSearch className="ml-2 size-3.5" />
<LuSearch className="size-3.5" />
</TooltipTrigger>
<TooltipContent>
{t("details.item.button.viewInExplore", {
@ -584,65 +750,85 @@ export default function TriggerView({
})}
</TooltipContent>
</Tooltip>
</div>
)}
</Link>
</div>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<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 text-white"
onClick={() => {
setSelectedTrigger(trigger);
setShowDelete(true);
}}
disabled={isLoading}
>
<LuTrash className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.deleteTrigger")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedTrigger(trigger);
setShowCreate(true);
}}
disabled={isLoading}
>
<LuPencil className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("triggers.table.edit")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.edit")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setSelectedTrigger(trigger);
setShowDelete(true);
}}
disabled={isLoading}
>
<LuTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("triggers.table.deleteTrigger")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.deleteTrigger")}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</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
show={showCreate}
show={showCreate && !!selectedTrigger && selectedTrigger.name !== ""}
trigger={selectedTrigger}
selectedCamera={selectedCamera}
isLoading={isLoading}