mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-11 17:47:37 +03:00
change layout
This commit is contained in:
parent
2f308af2ae
commit
275ec2b291
@ -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 &&
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user