mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-24 17:18:23 +03:00
Compare commits
2 Commits
893fe79d22
...
640007e5d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640007e5d3 | ||
|
|
710a77679b |
@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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 context—do 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 evidence—considering zone context, detected objects, and timing together—supports 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 evidence—considering zone, objects, time, and actions together—supports 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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -112,6 +112,7 @@
|
||||
},
|
||||
"imagePicker": {
|
||||
"selectImage": "Select a tracked object's thumbnail",
|
||||
"unknownLabel": "Saved Trigger Image",
|
||||
"search": {
|
||||
"placeholder": "Search by label or sub label..."
|
||||
},
|
||||
|
||||
@ -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.",
|
||||
|
||||
127
web/src/components/input/NameAndIdFields.tsx
Normal file
127
web/src/components/input/NameAndIdFields.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
255
web/src/components/trigger/TriggerWizardDialog.tsx
Normal file
255
web/src/components/trigger/TriggerWizardDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
web/src/components/trigger/wizard/Step1NameAndType.tsx
Normal file
200
web/src/components/trigger/wizard/Step1NameAndType.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
web/src/components/trigger/wizard/Step2ConfigureData.tsx
Normal file
133
web/src/components/trigger/wizard/Step2ConfigureData.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
web/src/components/trigger/wizard/Step3ThresholdAndActions.tsx
Normal file
194
web/src/components/trigger/wizard/Step3ThresholdAndActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user