Files
frigate/web/src/components/card/ClassificationCard.tsx
T

439 lines
13 KiB
TypeScript
Raw Normal View History

2025-10-07 13:43:06 -06:00
import { baseUrl } from "@/api/baseUrl";
import useContextMenu from "@/hooks/use-contextmenu";
import { cn } from "@/lib/utils";
import {
ClassificationItemData,
ClassificationThreshold,
2025-12-17 17:52:27 -06:00
ClassifiedEvent,
2025-10-07 13:43:06 -06:00
} from "@/types/classification";
2025-10-22 07:36:09 -06:00
import { forwardRef, useMemo, useRef, useState } from "react";
2025-12-11 08:23:34 -06:00
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
2025-10-07 13:43:06 -06:00
import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
2025-11-04 09:54:05 -07:00
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { LuSearch, LuInfo } from "react-icons/lu";
2025-10-07 13:43:06 -06:00
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { useNavigate } from "react-router-dom";
2025-10-22 07:36:09 -06:00
import { HiSquare2Stack } from "react-icons/hi2";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
MobilePageTrigger,
} from "../mobile/MobilePage";
2025-10-07 13:43:06 -06:00
type ClassificationCardProps = {
className?: string;
imgClassName?: string;
data: ClassificationItemData;
threshold?: ClassificationThreshold;
selected: boolean;
2025-12-18 15:12:10 -07:00
clickable: boolean;
2025-10-07 13:43:06 -06:00
i18nLibrary: string;
showArea?: boolean;
2025-10-22 07:36:09 -06:00
count?: number;
2025-10-07 13:43:06 -06:00
onClick: (data: ClassificationItemData, meta: boolean) => void;
children?: React.ReactNode;
};
2025-10-22 07:36:09 -06:00
export const ClassificationCard = forwardRef<
HTMLDivElement,
ClassificationCardProps
>(function ClassificationCard(
{
className,
imgClassName,
data,
threshold,
selected,
2025-12-18 15:12:10 -07:00
clickable,
2025-10-22 07:36:09 -06:00
i18nLibrary,
showArea = true,
count,
onClick,
children,
},
ref,
) {
2025-10-07 13:43:06 -06:00
const { t } = useTranslation([i18nLibrary]);
const [imageLoaded, setImageLoaded] = useState(false);
const scoreStatus = useMemo(() => {
if (!data.score || !threshold) {
return "unknown";
}
if (data.score >= threshold.recognition) {
return "match";
} else if (data.score >= threshold.unknown) {
return "potential";
} else {
return "unknown";
}
}, [data, threshold]);
// interaction
const imgRef = useRef<HTMLImageElement | null>(null);
useContextMenu(imgRef, () => {
onClick(data, true);
});
const imageArea = useMemo(() => {
if (!showArea || imgRef.current == null || !imageLoaded) {
return undefined;
}
return imgRef.current.naturalWidth * imgRef.current.naturalHeight;
}, [showArea, imageLoaded]);
return (
2025-10-22 07:36:09 -06:00
<div
ref={ref}
className={cn(
2025-12-18 15:12:10 -07:00
"relative flex size-full flex-col overflow-hidden rounded-lg outline outline-[3px]",
2025-10-22 07:36:09 -06:00
className,
selected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
2025-12-18 15:12:10 -07:00
clickable && "cursor-pointer",
2025-10-22 07:36:09 -06:00
)}
onClick={(e) => {
const isMeta = e.metaKey || e.ctrlKey;
if (isMeta) {
e.stopPropagation();
}
onClick(data, isMeta);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onClick(data, true);
}}
>
<img
ref={imgRef}
2025-10-07 13:43:06 -06:00
className={cn(
2025-10-22 07:36:09 -06:00
"absolute bottom-0 left-0 right-0 top-0 size-full",
imgClassName,
isMobile && "w-full",
2025-10-07 13:43:06 -06:00
)}
2025-12-11 08:23:34 -06:00
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
loading="lazy"
2025-10-22 07:36:09 -06:00
onLoad={() => setImageLoaded(true)}
src={`${baseUrl}${data.filepath}`}
/>
<ImageShadowOverlay upperClassName="z-0" lowerClassName="h-[30%] z-0" />
{count && (
<div className="absolute right-2 top-2 flex flex-row items-center gap-1">
<div className="text-gray-200">{count}</div>{" "}
<HiSquare2Stack className="text-gray-200" />
</div>
)}
{!count && imageArea != undefined && (
<div className="absolute right-1 top-1 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
{t("information.pixels", { ns: "common", area: imageArea })}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-[50%] bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-0 flex w-full select-none flex-row items-center justify-between gap-2 p-2">
<div
className={cn(
"flex flex-col items-start text-white",
2025-11-08 06:44:30 -06:00
data.score != undefined ? "text-xs" : "text-sm",
2025-10-22 07:36:09 -06:00
)}
>
2025-12-17 17:52:27 -06:00
<div className="break-all smart-capitalize">
2025-12-18 06:26:11 +08:00
{data.name == "unknown"
? t("details.unknown")
: data.name == "none"
? t("details.none")
: data.name}
2025-10-22 07:36:09 -06:00
</div>
2025-11-08 06:44:30 -06:00
{data.score != undefined && (
2025-10-22 07:36:09 -06:00
<div
className={cn(
"",
scoreStatus == "match" && "text-success",
scoreStatus == "potential" && "text-orange-400",
scoreStatus == "unknown" && "text-danger",
)}
>
{Math.round(data.score * 100)}%
2025-10-07 13:43:06 -06:00
</div>
)}
</div>
2025-10-22 07:36:09 -06:00
<div className="flex flex-row items-start justify-end gap-5 md:gap-2">
{children}
2025-10-07 13:43:06 -06:00
</div>
</div>
2025-10-22 07:36:09 -06:00
</div>
2025-10-07 13:43:06 -06:00
);
2025-10-22 07:36:09 -06:00
});
2025-10-07 13:43:06 -06:00
type GroupedClassificationCardProps = {
group: ClassificationItemData[];
2025-12-17 17:52:27 -06:00
classifiedEvent?: ClassifiedEvent;
2025-10-07 13:43:06 -06:00
threshold?: ClassificationThreshold;
selectedItems: string[];
i18nLibrary: string;
objectType: string;
2025-11-03 17:42:59 -07:00
noClassificationLabel?: string;
2025-10-07 13:43:06 -06:00
onClick: (data: ClassificationItemData | undefined) => void;
children?: (data: ClassificationItemData) => React.ReactNode;
};
export function GroupedClassificationCard({
group,
2025-12-17 17:52:27 -06:00
classifiedEvent,
2025-10-07 13:43:06 -06:00
threshold,
selectedItems,
i18nLibrary,
2025-11-03 17:42:59 -07:00
noClassificationLabel = "details.none",
2025-10-07 13:43:06 -06:00
onClick,
children,
}: GroupedClassificationCardProps) {
const navigate = useNavigate();
const { t } = useTranslation(["views/explore", i18nLibrary]);
2025-10-22 07:36:09 -06:00
const [detailOpen, setDetailOpen] = useState(false);
2025-10-07 13:43:06 -06:00
// data
2025-10-22 07:36:09 -06:00
const bestItem = useMemo<ClassificationItemData | undefined>(() => {
let best: undefined | ClassificationItemData = undefined;
group.forEach((item) => {
if (item?.name != undefined && item.name != "none") {
if (
best?.score == undefined ||
(item.score && best.score < item.score)
) {
best = item;
}
}
});
if (!best) {
2025-12-19 18:59:26 -06:00
best = group.at(-1)!;
2025-10-22 07:36:09 -06:00
}
const bestTyped: ClassificationItemData = best;
return {
...bestTyped,
2025-12-17 17:52:27 -06:00
name:
classifiedEvent?.label && classifiedEvent.label !== "none"
? classifiedEvent.label
: classifiedEvent
? t(noClassificationLabel)
: bestTyped.name,
score: classifiedEvent?.score,
2025-10-22 07:36:09 -06:00
};
2025-12-17 17:52:27 -06:00
}, [group, classifiedEvent, noClassificationLabel, t]);
2025-10-22 07:36:09 -06:00
const bestScoreStatus = useMemo(() => {
if (!bestItem?.score || !threshold) {
return "unknown";
}
if (bestItem.score >= threshold.recognition) {
return "match";
} else if (bestItem.score >= threshold.unknown) {
return "potential";
} else {
return "unknown";
}
}, [bestItem, threshold]);
2025-10-07 13:43:06 -06:00
const time = useMemo(() => {
const item = group[0];
if (!item?.timestamp) {
return undefined;
}
return item.timestamp * 1000;
}, [group]);
2025-10-22 07:36:09 -06:00
if (!bestItem) {
return null;
}
2025-10-07 13:43:06 -06:00
2025-10-22 07:36:09 -06:00
const Overlay = isDesktop ? Dialog : MobilePage;
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
const Content = isDesktop ? DialogContent : MobilePageContent;
2025-11-05 07:11:12 -07:00
const Header = isDesktop ? DialogHeader : MobilePageHeader;
2025-10-22 07:36:09 -06:00
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
const ContentDescription = isDesktop
? DialogDescription
: MobilePageDescription;
return (
<>
<ClassificationCard
data={bestItem}
threshold={threshold}
selected={selectedItems.includes(bestItem.filename)}
2025-12-18 15:12:10 -07:00
clickable={true}
2025-10-22 07:36:09 -06:00
i18nLibrary={i18nLibrary}
count={group.length}
onClick={(_, meta) => {
if (meta || selectedItems.length > 0) {
onClick(undefined);
} else {
setDetailOpen(true);
}
}}
/>
<Overlay
open={detailOpen}
onOpenChange={(open) => {
if (!open) {
setDetailOpen(false);
}
}}
2025-10-07 13:43:06 -06:00
>
2025-10-22 07:36:09 -06:00
<Trigger asChild></Trigger>
<Content
className={cn(
2025-11-05 07:11:12 -07:00
"scrollbar-container",
2025-10-22 07:36:09 -06:00
isDesktop && "min-w-[50%] max-w-[65%]",
2025-11-05 07:11:12 -07:00
isMobile && "overflow-y-auto",
2025-10-22 07:36:09 -06:00
)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<>
<Header
className={cn(
"mx-2 flex flex-row items-center gap-4",
2025-11-05 07:11:12 -07:00
isMobileOnly && "top-0 mx-4",
2025-10-22 07:36:09 -06:00
)}
>
2025-11-05 07:11:12 -07:00
<div
className={cn(
"",
isMobile && "flex flex-col items-center justify-center",
)}
>
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
2025-12-17 17:52:27 -06:00
{classifiedEvent?.label && classifiedEvent.label !== "none"
? classifiedEvent.label
2025-11-03 17:42:59 -07:00
: t(noClassificationLabel)}
2025-12-17 17:52:27 -06:00
{classifiedEvent?.label &&
classifiedEvent.label !== "none" &&
classifiedEvent.score !== undefined && (
<div className="flex items-center gap-1">
<div
className={cn(
"",
bestScoreStatus == "match" && "text-success",
bestScoreStatus == "potential" && "text-orange-400",
bestScoreStatus == "unknown" && "text-danger",
)}
>{`${Math.round((classifiedEvent.score || 0) * 100)}%`}</div>
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t("details.scoreInfo", {
ns: i18nLibrary,
})}
>
<LuInfo className="size-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("details.scoreInfo", { ns: i18nLibrary })}
</PopoverContent>
</Popover>
</div>
)}
2025-10-22 07:36:09 -06:00
</ContentTitle>
<ContentDescription className={cn("", isMobile && "px-2")}>
{time && (
<TimeAgo
className="text-sm text-secondary-foreground"
time={time}
dense
/>
)}
</ContentDescription>
</div>
2025-12-19 18:59:26 -06:00
{classifiedEvent && (
<div
className={cn(
"flex",
isDesktop && "flex-row justify-between",
isMobile && "absolute right-4 top-8",
2025-10-22 07:36:09 -06:00
)}
2025-12-19 18:59:26 -06:00
>
<Tooltip>
<TooltipTrigger asChild>
<div
className="cursor-pointer"
tabIndex={-1}
onClick={() => {
navigate(`/explore?event_id=${classifiedEvent.id}`);
}}
>
<LuSearch className="size-4 text-secondary-foreground" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.viewInExplore", {
ns: "views/explore",
})}
</TooltipContent>
</TooltipPortal>
</Tooltip>
2025-10-22 07:36:09 -06:00
</div>
)}
</Header>
<div
className={cn(
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
isDesktop && "p-2",
2025-11-05 07:11:12 -07:00
isMobile && "px-4 pb-4",
2025-10-22 07:36:09 -06:00
)}
>
{group.map((data: ClassificationItemData) => (
<div key={data.filename} className="aspect-square w-full">
<ClassificationCard
data={data}
threshold={threshold}
selected={false}
2025-12-18 15:12:10 -07:00
clickable={false}
2025-10-22 07:36:09 -06:00
i18nLibrary={i18nLibrary}
2025-11-17 08:12:05 -06:00
onClick={() => {}}
2025-10-22 07:36:09 -06:00
>
{children?.(data)}
</ClassificationCard>
</div>
))}
</div>
</>
</Content>
</Overlay>
</>
2025-10-07 13:43:06 -06:00
);
}