change layout

This commit is contained in:
Josh Hawkins 2025-10-30 08:45:22 -05:00
parent 2f308af2ae
commit 275ec2b291
2 changed files with 630 additions and 462 deletions

View File

@ -109,6 +109,7 @@ export default function SearchDetailDialog({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const apiHost = useApiHost();
// tabs // tabs
@ -118,6 +119,12 @@ export default function SearchDetailDialog({
100, 100,
); );
// tracking details state
const [trackingTimeIndex, setTrackingTimeIndex] = useState<
number | undefined
>(undefined);
// dialog and mobile page // dialog and mobile page
const [isOpen, setIsOpen] = useState(search != undefined); const [isOpen, setIsOpen] = useState(search != undefined);
@ -200,6 +207,9 @@ export default function SearchDetailDialog({
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
isDesktop && isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl", "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isDesktop &&
page == "tracking_details" &&
"lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4", isMobile && "px-4",
)} )}
> >
@ -209,70 +219,213 @@ export default function SearchDetailDialog({
{t("trackedObjectDetails")} {t("trackedObjectDetails")}
</Description> </Description>
</Header> </Header>
<ScrollArea {isDesktop ? (
className={cn("w-full whitespace-nowrap", isMobile && "my-2")} <div className="flex h-full gap-4 overflow-hidden">
> <div className="scrollbar-container flex-[3] overflow-y-auto">
<div className="flex flex-row"> {page === "snapshot" && search.has_snapshot && (
<ToggleGroup <ObjectSnapshotTab
className="*:rounded-md *:px-3 *:py-4" search={
type="single" {
size="sm" ...search,
value={pageToggle} plus_id: config?.plus?.enabled
onValueChange={(value: SearchTab) => { ? search.plus_id
if (value) { : "not_enabled",
setPageToggle(value); } as unknown as Event
} }
}} onEventUploaded={() => {
> search.plus_id = "new_upload";
{Object.values(searchTabs).map((item) => ( }}
<ToggleGroupItem />
key={item} )}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} {page === "video" && search.has_clip && (
value={item} <VideoTab search={search} />
data-nav-item={item} )}
aria-label={`Select ${item}`} {page === "tracking_details" && (
> <TrackingDetails
{item == "details" && <FaRegListAlt className="size-4" />} event={search as unknown as Event}
{item == "snapshot" && <FaImage className="size-4" />} showImage={true}
{item == "video" && <FaVideo className="size-4" />} showLifecycle={false}
{item == "tracking_details" && <PiPath className="size-4" />} timeIndex={trackingTimeIndex}
<div className="smart-capitalize">{t(`type.${item}`)}</div> setTimeIndex={setTrackingTimeIndex}
</ToggleGroupItem> />
))} )}
</ToggleGroup> {(page === "details" ||
<ScrollBar orientation="horizontal" className="h-0" /> (!search.has_snapshot && page === "snapshot") ||
(!search.has_clip && page === "video")) && (
<img
className="aspect-video select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
</div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "details" && (
<FaRegListAlt className="size-4" />
)}
{item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />}
{item == "tracking_details" && (
<PiPath className="size-4" />
)}
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
{page == "snapshot" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
{page == "video" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
{page == "tracking_details" && (
<TrackingDetails
className="w-full overflow-x-hidden"
event={search as unknown as Event}
showImage={false}
showLifecycle={true}
timeIndex={trackingTimeIndex}
setTimeIndex={setTrackingTimeIndex}
/>
)}
</div>
</div>
</div> </div>
</ScrollArea> ) : (
{page == "details" && ( <>
<ObjectDetailsTab <ScrollArea
search={search} className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
config={config} >
setSearch={setSearch} <div className="flex flex-row">
setSimilarity={setSimilarity} <ToggleGroup
setInputFocused={setInputFocused} className="*:rounded-md *:px-3 *:py-4"
/> type="single"
)} size="sm"
{page == "snapshot" && ( value={pageToggle}
<ObjectSnapshotTab onValueChange={(value: SearchTab) => {
search={ if (value) {
{ setPageToggle(value);
...search, }
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled", }}
} as unknown as Event >
} {Object.values(searchTabs).map((item) => (
onEventUploaded={() => { <ToggleGroupItem
search.plus_id = "new_upload"; key={item}
}} className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
/> value={item}
)} data-nav-item={item}
{page == "video" && <VideoTab search={search} />} aria-label={`Select ${item}`}
{page == "tracking_details" && ( >
<TrackingDetails {item == "details" && <FaRegListAlt className="size-4" />}
className="w-full overflow-x-hidden" {item == "snapshot" && <FaImage className="size-4" />}
event={search as unknown as Event} {item == "video" && <FaVideo className="size-4" />}
fullscreen={true} {item == "tracking_details" && (
setPane={() => {}} <PiPath className="size-4" />
/> )}
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
/>
)}
{page == "snapshot" && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page == "video" && <VideoTab search={search} />}
{page == "tracking_details" && (
<TrackingDetails
className="w-full overflow-x-hidden"
event={search as unknown as Event}
showImage={true}
showLifecycle={true}
timeIndex={trackingTimeIndex}
setTimeIndex={setTrackingTimeIndex}
/>
)}
</>
)} )}
</Content> </Content>
</Overlay> </Overlay>
@ -285,6 +438,7 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void; setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void; setSimilarity?: () => void;
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>; setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
showThumbnail?: boolean;
}; };
function ObjectDetailsTab({ function ObjectDetailsTab({
search, search,
@ -292,6 +446,7 @@ function ObjectDetailsTab({
setSearch, setSearch,
setSimilarity, setSimilarity,
setInputFocused, setInputFocused,
showThumbnail = true,
}: ObjectDetailsTabProps) { }: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@ -873,66 +1028,71 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-2 pl-6"> {showThumbnail && (
<img <div className="flex w-full flex-col gap-2 pl-6">
className="aspect-video select-none rounded-lg object-contain transition-opacity" <img
style={ className="aspect-video select-none rounded-lg object-contain transition-opacity"
isIOS style={
? { isIOS
WebkitUserSelect: "none", ? {
WebkitTouchCallout: "none", WebkitUserSelect: "none",
} WebkitTouchCallout: "none",
: undefined }
} : undefined
draggable={false} }
src={`${apiHost}api/events/${search.id}/thumbnail.webp`} draggable={false}
/> src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
<div />
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")} <div
> className={cn(
{config?.semantic_search.enabled && "flex w-full flex-row gap-2",
setSimilarity != undefined && isMobile && "flex-col",
search.data.type == "object" && ( )}
<Button >
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
search.data.type == "object" && (
<Button
className="w-full"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
setSimilarity();
}}
>
<div className="flex gap-1">
<LuSearch />
{t("itemMenu.findSimilar.label")}
</div>
</Button>
)}
{hasFace && (
<FaceSelectionDialog
className="w-full" className="w-full"
aria-label={t("itemMenu.findSimilar.aria")} faceNames={faceNames}
onClick={() => { onTrainAttempt={onTrainFace}
setSearch(undefined);
setSimilarity();
}}
> >
<div className="flex gap-1"> <Button className="w-full">
<LuSearch /> <div className="flex gap-1">
{t("itemMenu.findSimilar.label")} <TbFaceId />
</div> {t("trainFace", { ns: "views/faceLibrary" })}
</Button> </div>
)} </Button>
{hasFace && ( </FaceSelectionDialog>
<FaceSelectionDialog
className="w-full"
faceNames={faceNames}
onTrainAttempt={onTrainFace}
>
<Button className="w-full">
<div className="flex gap-1">
<TbFaceId />
{t("trainFace", { ns: "views/faceLibrary" })}
</div>
</Button>
</FaceSelectionDialog>
)}
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button className="w-full" onClick={onTranscribe}>
<div className="flex gap-1">
<CgTranscript />
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
)} )}
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button className="w-full" onClick={onTranscribe}>
<div className="flex gap-1">
<CgTranscript />
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
)}
</div>
</div> </div>
</div> )}
</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 && {config?.cameras[search.camera].objects.genai.enabled &&

View File

@ -5,7 +5,6 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
@ -18,7 +17,7 @@ import {
LuSettings, LuSettings,
LuTruck, LuTruck,
} from "react-icons/lu"; } from "react-icons/lu";
import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io"; import { IoMdExit } from "react-icons/io";
import { import {
MdFaceUnlock, MdFaceUnlock,
MdOutlineLocationOn, MdOutlineLocationOn,
@ -26,7 +25,7 @@ import {
} from "react-icons/md"; } from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isDesktop, isIOS, isSafari } from "react-device-detect"; import { isIOS, isSafari } from "react-device-detect";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { import {
Tooltip, Tooltip,
@ -63,14 +62,19 @@ type TrackingDetailsProps = {
className?: string; className?: string;
event: Event; event: Event;
fullscreen?: boolean; fullscreen?: boolean;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>; showImage?: boolean;
showLifecycle?: boolean;
timeIndex?: number;
setTimeIndex?: (index: number) => void;
}; };
export default function TrackingDetails({ export default function TrackingDetails({
className, className,
event, event,
fullscreen = false, showImage = true,
setPane, showLifecycle = false,
timeIndex: propTimeIndex,
setTimeIndex: propSetTimeIndex,
}: TrackingDetailsProps) { }: TrackingDetailsProps) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
@ -214,7 +218,11 @@ export default function TrackingDetails({
); );
}, [savedPathPoints, eventSequencePoints, config, event]); }, [savedPathPoints, eventSequencePoints, config, event]);
const [timeIndex, setTimeIndex] = useState(0); const [localTimeIndex, setLocalTimeIndex] = useState<number>(0);
const timeIndex =
propTimeIndex !== undefined ? propTimeIndex : localTimeIndex;
const setTimeIndex = propSetTimeIndex || setLocalTimeIndex;
const handleSetBox = useCallback( const handleSetBox = useCallback(
(box: number[], attrBox: number[] | undefined) => { (box: number[], attrBox: number[] | undefined) => {
@ -257,15 +265,15 @@ export default function TrackingDetails({
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
useEffect(() => { useEffect(() => {
if (timeIndex) { if (propTimeIndex !== undefined) {
const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`; const newSrc = `${apiHost}api/${event.camera}/recordings/${propTimeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`;
setSrc(newSrc); setSrc(newSrc);
} }
setImgLoaded(false); setImgLoaded(false);
setHasError(false); setHasError(false);
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeIndex, annotationOffset]); }, [propTimeIndex, annotationOffset]);
// carousels // carousels
@ -291,7 +299,7 @@ export default function TrackingDetails({
setLifecycleZones([]); setLifecycleZones([]);
} }
}, },
[eventSequence, pathPoints, handleSetBox], [eventSequence, pathPoints, handleSetBox, setTimeIndex],
); );
const formattedStart = config const formattedStart = config
@ -329,7 +337,7 @@ export default function TrackingDetails({
useEffect(() => { useEffect(() => {
if (!eventSequence || eventSequence.length === 0) return; if (!eventSequence || eventSequence.length === 0) return;
// If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp // If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp
if (!timeIndex) { if (timeIndex == null || timeIndex === 0) {
setTimeIndex(eventSequence[0].timestamp); setTimeIndex(eventSequence[0].timestamp);
handleSetBox( handleSetBox(
eventSequence[0]?.data.box ?? [], eventSequence[0]?.data.box ?? [],
@ -337,12 +345,12 @@ export default function TrackingDetails({
); );
setLifecycleZones(eventSequence[0]?.data.zones); setLifecycleZones(eventSequence[0]?.data.zones);
} }
}, [eventSequence, timeIndex, handleSetBox]); }, [eventSequence, timeIndex, handleSetBox, setTimeIndex]);
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear // When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear
useEffect(() => { useEffect(() => {
if (!eventSequence || timeIndex == null) return; if (!eventSequence || propTimeIndex == null) return;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); const idx = eventSequence.findIndex((i) => i.timestamp === propTimeIndex);
if (idx !== -1) { if (idx !== -1) {
if (imgLoaded) { if (imgLoaded) {
handleSetBox( handleSetBox(
@ -356,7 +364,7 @@ export default function TrackingDetails({
setBoxStyle(null); setBoxStyle(null);
setLifecycleZones([]); setLifecycleZones([]);
} }
}, [timeIndex, imgLoaded, eventSequence, handleSetBox]); }, [propTimeIndex, imgLoaded, eventSequence, handleSetBox]);
const selectedLifecycle = useMemo(() => { const selectedLifecycle = useMemo(() => {
if (!eventSequence || eventSequence.length === 0) return undefined; if (!eventSequence || eventSequence.length === 0) return undefined;
@ -423,344 +431,344 @@ export default function TrackingDetails({
return ( return (
<div className={className}> <div className={className}>
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{!fullscreen && (
<div className={cn("flex items-center gap-2")}> {showImage && (
<Button <div
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0" className={cn(
aria-label={t("label.back", { ns: "common" })} "relative mx-auto flex max-h-[50dvh] flex-row justify-center",
size="sm" )}
onClick={() => setPane("overview")} style={{
aspectRatio: !imgLoaded ? aspectRatio : undefined,
}}
>
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
/>
{hasError && (
<div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" />
{t("trackingDetails.noImageFound")}
</div>
</div>
)}
<div
className={cn(
"relative inline-block",
imgLoaded ? "visible" : "invisible",
)}
> >
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> <ContextMenu>
{isDesktop && ( <ContextMenuTrigger>
<div className="text-primary"> <img
{t("button.back", { ns: "common" })} key={event.id}
</div> ref={imgRef}
)} className={cn(
</Button> "max-h-[50dvh] max-w-full select-none rounded-lg object-contain",
</div> )}
)} loading={isSafari ? "eager" : "lazy"}
style={
<div isIOS
className={cn( ? {
"relative mx-auto flex max-h-[50dvh] flex-row justify-center", WebkitUserSelect: "none",
)} WebkitTouchCallout: "none",
style={{ }
aspectRatio: !imgLoaded ? aspectRatio : undefined, : undefined
}}
>
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
/>
{hasError && (
<div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" />
{t("trackingDetails.noImageFound")}
</div>
</div>
)}
<div
className={cn(
"relative inline-block",
imgLoaded ? "visible" : "invisible",
)}
>
<ContextMenu>
<ContextMenuTrigger>
<img
key={event.id}
ref={imgRef}
className={cn(
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain",
)}
loading={isSafari ? "eager" : "lazy"}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={src}
onLoad={() => setImgLoaded(true)}
onError={() => setHasError(true)}
/>
{showZones &&
imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key={zone}
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<polygon
points={getZonePolygon(zone)}
className="fill-none stroke-2"
style={{
stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
fill:
selectedZone == zone
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
strokeWidth: selectedZone == zone ? 4 : 2,
}}
/>
</svg>
</div>
))}
{boxStyle && (
<div className="absolute border-2" style={boxStyle}>
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
</div>
)}
{attributeBoxStyle && (
<div className="absolute border-2" style={attributeBoxStyle} />
)}
{imgRef.current?.width &&
imgRef.current?.height &&
pathPoints &&
pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<ObjectPath
positions={pathPoints}
color={getObjectColor(event.label)}
width={2}
imgRef={imgRef}
onPointClick={handlePathPointClick}
/>
</svg>
</div>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
)
} }
draggable={false}
src={src}
onLoad={() => setImgLoaded(true)}
onError={() => setHasError(true)}
/>
{showZones &&
imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key={zone}
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<polygon
points={getZonePolygon(zone)}
className="fill-none stroke-2"
style={{
stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
fill:
selectedZone == zone
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
strokeWidth: selectedZone == zone ? 4 : 2,
}}
/>
</svg>
</div>
))}
{boxStyle && (
<div className="absolute border-2" style={boxStyle}>
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
</div>
)}
{attributeBoxStyle && (
<div
className="absolute border-2"
style={attributeBoxStyle}
/>
)}
{imgRef.current?.width &&
imgRef.current?.height &&
pathPoints &&
pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<ObjectPath
positions={pathPoints}
color={getObjectColor(event.label)}
width={2}
imgRef={imgRef}
onPointClick={handlePathPointClick}
/>
</svg>
</div>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
)
}
>
<div className="text-primary">
{t("trackingDetails.createObjectMask")}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
)}
{showLifecycle && (
<>
<div className="mt-3 flex flex-row items-center justify-between">
<Heading as="h4">{t("trackingDetails.title")}</Heading>
<div className="flex flex-row gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showControls ? "select" : "default"}
className="size-7 p-1.5"
aria-label={t("trackingDetails.adjustAnnotationSettings")}
>
<LuSettings
className="size-5"
onClick={() => setShowControls(!showControls)}
/>
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("trackingDetails.adjustAnnotationSettings")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="mb-2 text-sm text-muted-foreground">
{t("trackingDetails.scrollViewTips")}
</div>
<div className="min-w-20 text-right text-sm text-muted-foreground">
{t("trackingDetails.count", {
first: selectedIndex + 1,
second: eventSequence?.length ?? 0,
})}
</div>
</div>
{config?.cameras[event.camera]?.onvif.autotracking
.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")}
</div>
)}
{showControls && (
<AnnotationSettingsPane
event={event}
showZones={showZones}
setShowZones={setShowZones}
annotationOffset={annotationOffset}
setAnnotationOffset={setAnnotationOffset}
/>
)}
<div className="mt-4">
<div
className={cn(
"rounded-md bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
)}
>
<div className="flex w-full items-center justify-between">
<div
className="flex items-center gap-2 font-medium"
onClick={(e) => {
e.stopPropagation();
setTimeIndex(event.start_time ?? 0);
}}
role="button"
> >
<div className="text-primary"> <div
{t("trackingDetails.createObjectMask")} className={cn(
"relative ml-2 rounded-full bg-muted-foreground p-2",
)}
>
{getIconForLabel(
event.sub_label ? event.label + "-verified" : event.label,
"size-4 text-white",
)}
</div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span>
<span className="text-secondary-foreground">
{formattedStart ?? ""} - {formattedEnd ?? ""}
</span>
{event.data?.recognized_license_plate && (
<>
<span className="text-secondary-foreground">·</span>
<div className="text-sm text-secondary-foreground">
<Link
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
className="text-sm"
>
{event.data.recognized_license_plate}
</Link>
</div>
</>
)}
</div> </div>
</div> </div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
<div className="mt-3 flex flex-row items-center justify-between">
<Heading as="h4">{t("trackingDetails.title")}</Heading>
<div className="flex flex-row gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showControls ? "select" : "default"}
className="size-7 p-1.5"
aria-label={t("trackingDetails.adjustAnnotationSettings")}
>
<LuSettings
className="size-5"
onClick={() => setShowControls(!showControls)}
/>
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("trackingDetails.adjustAnnotationSettings")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="mb-2 text-sm text-muted-foreground">
{t("trackingDetails.scrollViewTips")}
</div>
<div className="min-w-20 text-right text-sm text-muted-foreground">
{t("trackingDetails.count", {
first: selectedIndex + 1,
second: eventSequence?.length ?? 0,
})}
</div>
</div>
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")}
</div>
)}
{showControls && (
<AnnotationSettingsPane
event={event}
showZones={showZones}
setShowZones={setShowZones}
annotationOffset={annotationOffset}
setAnnotationOffset={setAnnotationOffset}
/>
)}
<div className="mt-4">
<div
className={cn(
"rounded-md bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
)}
>
<div className="flex w-full items-center justify-between">
<div
className="flex items-center gap-2 font-medium"
onClick={(e) => {
e.stopPropagation();
setTimeIndex(event.start_time ?? 0);
}}
role="button"
>
<div
className={cn(
"relative ml-2 rounded-full bg-muted-foreground p-2",
)}
>
{getIconForLabel(
event.sub_label ? event.label + "-verified" : event.label,
"size-4 text-white",
)}
</div> </div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span> <div className="mt-2">
<span className="text-secondary-foreground"> {!eventSequence ? (
{formattedStart ?? ""} - {formattedEnd ?? ""} <ActivityIndicator className="size-2" size={2} />
</span> ) : eventSequence.length === 0 ? (
{event.data?.recognized_license_plate && ( <div className="py-2 text-muted-foreground">
<> {t("detail.noObjectDetailData", { ns: "views/events" })}
<span className="text-secondary-foreground">·</span> </div>
<div className="text-sm text-secondary-foreground"> ) : (
<Link <div className="-pb-2 relative mx-2">
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`} <div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
className="text-sm" <div
> className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
{event.data.recognized_license_plate} style={{ height: `${blueLineHeight}%` }}
</Link> />
<div className="space-y-2">
{eventSequence.map((item, idx) => {
const isActive =
Math.abs(
(propTimeIndex ?? 0) - (item.timestamp ?? 0),
) <= 0.5;
const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t(
"time.formattedTimestampHourMinuteSecond.24hour",
{ ns: "common" },
)
: t(
"time.formattedTimestampHourMinuteSecond.12hour",
{ ns: "common" },
),
time_style: "medium",
date_style: "medium",
})
: "";
const ratio =
Array.isArray(item.data.box) &&
item.data.box.length >= 4
? (
aspectRatio *
(item.data.box[2] / item.data.box[3])
).toFixed(2)
: "N/A";
const areaPx =
Array.isArray(item.data.box) &&
item.data.box.length >= 4
? Math.round(
(config.cameras[event.camera]?.detect?.width ??
0) *
(config.cameras[event.camera]?.detect
?.height ?? 0) *
(item.data.box[2] * item.data.box[3]),
)
: undefined;
const areaPct =
Array.isArray(item.data.box) &&
item.data.box.length >= 4
? (item.data.box[2] * item.data.box[3]).toFixed(4)
: undefined;
return (
<LifecycleIconRow
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
item={item}
isActive={isActive}
formattedEventTimestamp={formattedEventTimestamp}
ratio={ratio}
areaPx={areaPx}
areaPct={areaPct}
onClick={() => {
setTimeIndex(item.timestamp ?? 0);
handleSetBox(
item.data.box ?? [],
item.data.attribute_box,
);
setLifecycleZones(item.data.zones);
setSelectedZone("");
}}
setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor}
/>
);
})}
</div> </div>
</> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</>
<div className="mt-2"> )}
{!eventSequence ? (
<ActivityIndicator className="size-2" size={2} />
) : eventSequence.length === 0 ? (
<div className="py-2 text-muted-foreground">
{t("detail.noObjectDetailData", { ns: "views/events" })}
</div>
) : (
<div className="-pb-2 relative mx-2">
<div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
<div
className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }}
/>
<div className="space-y-2">
{eventSequence.map((item, idx) => {
const isActive =
Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5;
const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t(
"time.formattedTimestampHourMinuteSecond.24hour",
{ ns: "common" },
)
: t(
"time.formattedTimestampHourMinuteSecond.12hour",
{ ns: "common" },
),
time_style: "medium",
date_style: "medium",
})
: "";
const ratio =
Array.isArray(item.data.box) && item.data.box.length >= 4
? (
aspectRatio *
(item.data.box[2] / item.data.box[3])
).toFixed(2)
: "N/A";
const areaPx =
Array.isArray(item.data.box) && item.data.box.length >= 4
? Math.round(
(config.cameras[event.camera]?.detect?.width ?? 0) *
(config.cameras[event.camera]?.detect?.height ??
0) *
(item.data.box[2] * item.data.box[3]),
)
: undefined;
const areaPct =
Array.isArray(item.data.box) && item.data.box.length >= 4
? (item.data.box[2] * item.data.box[3]).toFixed(4)
: undefined;
return (
<LifecycleIconRow
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
item={item}
isActive={isActive}
formattedEventTimestamp={formattedEventTimestamp}
ratio={ratio}
areaPx={areaPx}
areaPct={areaPct}
onClick={() => {
setTimeIndex(item.timestamp ?? 0);
handleSetBox(
item.data.box ?? [],
item.data.attribute_box,
);
setLifecycleZones(item.data.zones);
setSelectedZone("");
}}
setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor}
/>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
</div> </div>
); );
} }