mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
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:
parent
01452e4c51
commit
c99ada8f6a
@ -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}}",
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user