Tracked Object Details pane tweaks (#20849)

* use grid view on desktop

* refactor description box to remove buttons and add row of action icon buttons

* add tooltips

* fix trigger creation

when using the search effect to create a trigger, the prefilled object will not exist in the config yet

* i18n

* set max width on thumbnail
This commit is contained in:
Josh Hawkins 2025-11-08 13:26:30 -06:00 committed by GitHub
parent 01452e4c51
commit c99ada8f6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 204 additions and 133 deletions

View File

@ -96,7 +96,9 @@
"back": "Go back", "back": "Go back",
"hide": "Hide {{item}}", "hide": "Hide {{item}}",
"show": "Show {{item}}", "show": "Show {{item}}",
"ID": "ID" "ID": "ID",
"none": "None",
"all": "All"
}, },
"list": { "list": {
"two": "{{0}} and {{1}}", "two": "{{0}} and {{1}}",

View File

@ -159,7 +159,7 @@ export default function CreateTriggerDialog({
}); });
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (trigger) { if (trigger && existingTriggerNames.includes(trigger.name)) {
onEdit({ ...values }); onEdit({ ...values });
} else { } else {
onCreate( onCreate(

View File

@ -34,9 +34,11 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { import {
FaArrowRight, FaArrowRight,
FaCheckCircle, FaCheckCircle,
FaChevronDown,
FaChevronLeft, FaChevronLeft,
FaChevronRight, FaChevronRight,
FaMicrophone,
FaCheck,
FaTimes,
} from "react-icons/fa"; } from "react-icons/fa";
import { TrackingDetails } from "./TrackingDetails"; import { TrackingDetails } from "./TrackingDetails";
import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
@ -89,6 +91,7 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { DialogPortal } from "@radix-ui/react-dialog"; import { DialogPortal } from "@radix-ui/react-dialog";
import { useDetailStream } from "@/context/detail-stream-context"; import { useDetailStream } from "@/context/detail-stream-context";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";
import { HiSparkles } from "react-icons/hi";
const SEARCH_TABS = ["snapshot", "tracking_details"] as const; const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
export type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
@ -348,7 +351,12 @@ function DialogContentComponent({
} }
/> />
) : ( ) : (
<div className={cn(!isDesktop ? "mb-4 w-full md:max-w-lg" : "size-full")}> <div
className={cn(
"max-w-lg",
!isDesktop ? "mb-4 w-full" : "mx-auto size-full",
)}
>
<img <img
className="w-full select-none rounded-lg object-contain transition-opacity" className="w-full select-none rounded-lg object-contain transition-opacity"
style={ style={
@ -367,16 +375,11 @@ function DialogContentComponent({
if (isDesktop) { if (isDesktop) {
return ( return (
<div className="flex h-full gap-4 overflow-hidden"> <div className="grid h-full w-full grid-cols-[60%_40%] gap-4">
<div <div className="scrollbar-container min-w-0 overflow-y-auto overflow-x-hidden">
className={cn(
"scrollbar-container flex-[3] overflow-y-hidden",
!search.has_snapshot && "flex-[2]",
)}
>
{snapshotElement} {snapshotElement}
</div> </div>
<div className="flex flex-col gap-4 overflow-hidden md:basis-2/5"> <div className="flex min-w-0 flex-col gap-4 pr-2">
<TabsWithActions <TabsWithActions
search={search} search={search}
searchTabs={searchTabs} searchTabs={searchTabs}
@ -389,7 +392,7 @@ function DialogContentComponent({
setIsPopoverOpen={setIsPopoverOpen} setIsPopoverOpen={setIsPopoverOpen}
dialogContainer={dialogContainer} dialogContainer={dialogContainer}
/> />
<div className="scrollbar-container flex-1 overflow-y-auto"> <div className="scrollbar-container min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-4">
<ObjectDetailsTab <ObjectDetailsTab
search={search} search={search}
config={config} config={config}
@ -689,6 +692,8 @@ function ObjectDetailsTab({
const [desc, setDesc] = useState(search?.data.description); const [desc, setDesc] = useState(search?.data.description);
const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false); const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false);
const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false); const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false);
const [isEditingDesc, setIsEditingDesc] = useState(false);
const originalDescRef = useRef<string | null>(null);
const handleDescriptionFocus = useCallback(() => { const handleDescriptionFocus = useCallback(() => {
setInputFocused(true); setInputFocused(true);
@ -1119,6 +1124,23 @@ function ObjectDetailsTab({
); );
const popoverContainerRef = useRef<HTMLDivElement | null>(null); const popoverContainerRef = useRef<HTMLDivElement | null>(null);
const canRegenerate = !!(
config?.cameras[search.camera].objects.genai.enabled && search.end_time
);
const showGenAIPlaceholder = !!(
config?.cameras[search.camera].objects.genai.enabled &&
!search.end_time &&
(config.cameras[search.camera].objects.genai.required_zones.length === 0 ||
search.zones.some((zone) =>
config.cameras[search.camera].objects.genai.required_zones.includes(
zone,
),
)) &&
(config.cameras[search.camera].objects.genai.objects.length === 0 ||
config.cameras[search.camera].objects.genai.objects.includes(
search.label,
))
);
return ( return (
<div ref={popoverContainerRef} className="flex flex-col gap-5"> <div ref={popoverContainerRef} className="flex flex-col gap-5">
<div className="flex w-full flex-row"> <div className="flex w-full flex-row">
@ -1379,75 +1401,68 @@ function ObjectDetailsTab({
</div> </div>
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled && <div className="flex items-center justify-start gap-3">
!search.end_time && <div className="text-sm text-primary/40">
(config.cameras[search.camera].objects.genai.required_zones.length === {t("details.description.label")}
0 || </div>
search.zones.some((zone) => <div className="flex items-center gap-3">
config.cameras[search.camera].objects.genai.required_zones.includes( <Tooltip>
zone, <TooltipTrigger asChild>
), <button
)) && aria-label={t("button.edit", { ns: "common" })}
(config.cameras[search.camera].objects.genai.objects.length === 0 || className="text-primary/40 hover:text-primary/80"
config.cameras[search.camera].objects.genai.objects.includes( onClick={() => {
search.label, originalDescRef.current = desc ?? "";
)) ? ( setIsEditingDesc(true);
<> }}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="flex h-64 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40">
<div className="flex">
<ActivityIndicator />
</div>
<div className="flex">{t("details.description.aiTips")}</div>
</div>
</>
) : (
<>
<div className="text-sm text-primary/40"></div>
<Textarea
className="text-md h-64"
placeholder={t("details.description.placeholder")}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onFocus={handleDescriptionFocus}
onBlur={handleDescriptionBlur}
/>
</>
)}
<div className="flex w-full flex-row justify-end gap-2">
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button onClick={onTranscribe}>
<div className="flex gap-1">
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
)}
{config?.cameras[search.camera].objects.genai.enabled &&
search.end_time && (
<div className="flex items-start">
<Button
className="rounded-r-none border-r-0"
aria-label={t("details.button.regenerate.label")}
onClick={() => regenerateDescription("thumbnails")}
> >
{t("details.button.regenerate.title")} <FaPencilAlt className="size-4" />
</Button> </button>
{search.has_snapshot && ( </TooltipTrigger>
<DropdownMenu> <TooltipContent>
<DropdownMenuTrigger asChild> {t("button.edit", { ns: "common" })}
<Button </TooltipContent>
className="rounded-l-none border-l-0 px-2" </Tooltip>
aria-label={t("details.expandRegenerationMenu")}
> {config?.cameras[search?.camera].audio_transcription.enabled &&
<FaChevronDown className="size-3" /> search?.label == "speech" &&
</Button> search?.end_time && (
</DropdownMenuTrigger> <Tooltip>
<DropdownMenuContent> <TooltipTrigger asChild>
<button
aria-label={t("itemMenu.audioTranscription.label")}
className="text-primary/40 hover:text-primary/80"
onClick={onTranscribe}
>
<FaMicrophone className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.audioTranscription.label")}
</TooltipContent>
</Tooltip>
)}
{canRegenerate && (
<div className="relative">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
aria-label={t("details.button.regenerate.label")}
className="text-primary/40 hover:text-primary/80"
>
<HiSparkles className="size-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
{t("details.button.regenerate.title")}
</TooltipContent>
</Tooltip>
<DropdownMenuContent>
{search.has_snapshot && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
aria-label={t("details.regenerateFromSnapshot")} aria-label={t("details.regenerateFromSnapshot")}
@ -1455,61 +1470,115 @@ function ObjectDetailsTab({
> >
{t("details.regenerateFromSnapshot")} {t("details.regenerateFromSnapshot")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem )}
className="cursor-pointer" <DropdownMenuItem
aria-label={t("details.regenerateFromThumbnails")} className="cursor-pointer"
onClick={() => regenerateDescription("thumbnails")} aria-label={t("details.regenerateFromThumbnails")}
> onClick={() => regenerateDescription("thumbnails")}
{t("details.regenerateFromThumbnails")} >
</DropdownMenuItem> {t("details.regenerateFromThumbnails")}
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
)} </DropdownMenu>
</div> </div>
)} )}
{((config?.cameras[search.camera].objects.genai.enabled && </div>
search.end_time) ||
!config?.cameras[search.camera].objects.genai.enabled) && (
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
onClick={updateDescription}
>
{t("button.save", { ns: "common" })}
</Button>
)}
<TextEntryDialog
open={isSubLabelDialogOpen}
setOpen={setIsSubLabelDialogOpen}
title={t("details.editSubLabel.title")}
description={
search.label
? t("details.editSubLabel.desc", {
label: search.label,
})
: t("details.editSubLabel.descNoLabel")
}
onSave={handleSubLabelSave}
defaultValue={search?.sub_label || ""}
allowEmpty={true}
/>
<TextEntryDialog
open={isLPRDialogOpen}
setOpen={setIsLPRDialogOpen}
title={t("details.editLPR.title")}
description={
search.label
? t("details.editLPR.desc", {
label: search.label,
})
: t("details.editLPR.descNoLabel")
}
onSave={handleLPRSave}
defaultValue={search?.data.recognized_license_plate || ""}
allowEmpty={true}
/>
</div> </div>
{!isEditingDesc ? (
showGenAIPlaceholder ? (
<div className="flex h-32 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40">
<div className="flex">
<ActivityIndicator />
</div>
<div className="flex">{t("details.description.aiTips")}</div>
</div>
) : (
<div className="overflow-auto text-sm text-primary">
{desc || t("label.none", { ns: "common" })}
</div>
)
) : (
<div className="flex flex-col gap-2">
<Textarea
className="text-md h-32"
placeholder={t("details.description.placeholder")}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onFocus={handleDescriptionFocus}
onBlur={handleDescriptionBlur}
autoFocus
/>
<div className="flex flex-row justify-end gap-4">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={t("button.save", { ns: "common" })}
className="text-primary/40 hover:text-primary/80"
onClick={() => {
setIsEditingDesc(false);
updateDescription();
}}
>
<FaCheck className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("button.save", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={t("button.cancel", { ns: "common" })}
className="text-primary/40 hover:text-primary"
onClick={() => {
setIsEditingDesc(false);
setDesc(originalDescRef.current ?? "");
}}
>
<FaTimes className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("button.cancel", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
</div>
)}
<TextEntryDialog
open={isSubLabelDialogOpen}
setOpen={setIsSubLabelDialogOpen}
title={t("details.editSubLabel.title")}
description={
search.label
? t("details.editSubLabel.desc", {
label: search.label,
})
: t("details.editSubLabel.descNoLabel")
}
onSave={handleSubLabelSave}
defaultValue={search?.sub_label || ""}
allowEmpty={true}
/>
<TextEntryDialog
open={isLPRDialogOpen}
setOpen={setIsLPRDialogOpen}
title={t("details.editLPR.title")}
description={
search.label
? t("details.editLPR.desc", {
label: search.label,
})
: t("details.editLPR.descNoLabel")
}
onSave={handleLPRSave}
defaultValue={search?.data.recognized_license_plate || ""}
allowEmpty={true}
/>
</div> </div>
</div> </div>
); );