From d9216d39e61792274a2a45cb1b57827665edafde Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 21 Oct 2025 15:48:59 -0600 Subject: [PATCH] Refactor image grouping --- .../components/card/ClassificationCard.tsx | 324 ++++++++++++------ web/src/components/ui/dialog.tsx | 2 +- web/src/pages/FaceLibrary.tsx | 72 ++-- 3 files changed, 235 insertions(+), 163 deletions(-) diff --git a/web/src/components/card/ClassificationCard.tsx b/web/src/components/card/ClassificationCard.tsx index 71ddf34c5..c074a42a4 100644 --- a/web/src/components/card/ClassificationCard.tsx +++ b/web/src/components/card/ClassificationCard.tsx @@ -6,7 +6,7 @@ import { ClassificationThreshold, } from "@/types/classification"; import { Event } from "@/types/event"; -import { useMemo, useRef, useState } from "react"; +import { forwardRef, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import TimeAgo from "../dynamic/TimeAgo"; @@ -14,7 +14,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuSearch } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { useNavigate } from "react-router-dom"; -import { getTranslatedLabel } from "@/utils/i18n"; +import { HiSquare2Stack } from "react-icons/hi2"; +import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { + Drawer, + DrawerTrigger, + DrawerContent, + DrawerTitle, + DrawerDescription, +} from "../ui/drawer"; type ClassificationCardProps = { imgClassName?: string; @@ -23,19 +38,27 @@ type ClassificationCardProps = { selected: boolean; i18nLibrary: string; showArea?: boolean; + count?: number; onClick: (data: ClassificationItemData, meta: boolean) => void; children?: React.ReactNode; }; -export function ClassificationCard({ - imgClassName, - data, - threshold, - selected, - i18nLibrary, - showArea = true, - onClick, - children, -}: ClassificationCardProps) { +export const ClassificationCard = forwardRef< + HTMLDivElement, + ClassificationCardProps +>(function ClassificationCard( + { + imgClassName, + data, + threshold, + selected, + i18nLibrary, + showArea = true, + count, + onClick, + children, + }, + ref, +) { const { t } = useTranslation([i18nLibrary]); const [imageLoaded, setImageLoaded] = useState(false); @@ -71,12 +94,25 @@ export function ClassificationCard({ return (
{ + const isMeta = e.metaKey || e.ctrlKey; + if (isMeta) { + e.stopPropagation(); + } + onClick(data, isMeta); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick(data, true); + }} > setImageLoaded(true)} src={`${baseUrl}${data.filepath}`} - onClick={(e) => { - e.stopPropagation(); - onClick(data, e.metaKey || e.ctrlKey); - }} /> - {false && imageArea != undefined && ( -
+ + {count && ( +
+
{count}
{" "} + +
+ )} + {!count && imageArea != undefined && ( +
{t("information.pixels", { ns: "common", area: imageArea })}
)} @@ -127,7 +166,7 @@ export function ClassificationCard({
); -} +}); type GroupedClassificationCardProps = { group: ClassificationItemData[]; @@ -137,7 +176,6 @@ type GroupedClassificationCardProps = { i18nLibrary: string; objectType: string; onClick: (data: ClassificationItemData | undefined) => void; - onSelectEvent: (event: Event) => void; children?: (data: ClassificationItemData) => React.ReactNode; }; export function GroupedClassificationCard({ @@ -146,20 +184,48 @@ export function GroupedClassificationCard({ threshold, selectedItems, i18nLibrary, - objectType, onClick, - onSelectEvent, children, }: GroupedClassificationCardProps) { const navigate = useNavigate(); const { t } = useTranslation(["views/explore", i18nLibrary]); + const [detailOpen, setDetailOpen] = useState(false); // data - const allItemsSelected = useMemo( - () => group.every((data) => selectedItems.includes(data.filename)), - [group, selectedItems], - ); + const bestItem = useMemo(() => { + let best: undefined | ClassificationItemData = undefined; + + group.forEach((item) => { + if (best?.score == undefined || (item.score && best.score < item.score)) { + best = item; + } + }); + + if (!best) { + return undefined; + } + + const bestTyped: ClassificationItemData = best; + return { + ...bestTyped, + score: event?.data?.sub_label_score || bestTyped.score, + }; + }, [group, event]); + + 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]); const time = useMemo(() => { const item = group[0]; @@ -171,94 +237,126 @@ export function GroupedClassificationCard({ return item.timestamp * 1000; }, [group]); - return ( -
{ - if (selectedItems.length) { - onClick(undefined); - } - }} - onContextMenu={(e) => { - e.stopPropagation(); - e.preventDefault(); - onClick(undefined); - }} - > -
-
-
- {getTranslatedLabel(objectType)} - {event?.sub_label - ? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)` - : ": " + t("details.unknown")} -
- {time && ( - - )} -
- {event && ( - - -
{ - navigate(`/explore?event_id=${event.id}`); - }} - > - -
-
- - - {t("details.item.button.viewInExplore", { - ns: "views/explore", - })} - - -
- )} -
+ if (!bestItem) { + return null; + } -
+ { + if (meta || selectedItems.length > 0) { + onClick(undefined); + } else { + setDetailOpen(true); + } + }} + /> + { + if (!open) { + setDetailOpen(false); + } + }} > - {group.map((data: ClassificationItemData) => ( - { - if (meta || selectedItems.length > 0) { - onClick(data); - } else if (event) { - onSelectEvent(event); - } - }} - > - {children?.(data)} - - ))} -
-
+ + + <> + {isDesktop && ( +
+ {event && ( + + +
{ + navigate(`/explore?event_id=${event.id}`); + }} + > + +
+
+ + + {t("details.item.button.viewInExplore", { + ns: "views/explore", + })} + + +
+ )} +
+ )} + + {event?.sub_label ? event.sub_label : t("details.unknown")} + {event?.sub_label && ( +
{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}
+ )} +
+ + {time && ( + + )} + +
+
+ {group.map((data: ClassificationItemData) => ( + { + if (meta || selectedItems.length > 0) { + onClick(data); + } + }} + > + {children?.(data)} + + ))} +
+
+ +
+ + ); } diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 761d815be..65a861012 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -107,7 +107,7 @@ const DialogContent = React.forwardRef< > {children} - + Close diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 20949fa41..a982f7df0 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -63,10 +63,6 @@ import { } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; -import SearchDetailDialog, { - SearchTab, -} from "@/components/overlay/detail/SearchDetailDialog"; -import { SearchResult } from "@/types/search"; import { ClassificationCard, GroupedClassificationCard, @@ -686,11 +682,6 @@ function TrainingGrid({ { ids: eventIdsQuery }, ]); - // selection - - const [selectedEvent, setSelectedEvent] = useState(); - const [dialogTab, setDialogTab] = useState("details"); - if (attemptImages.length == 0) { return (
@@ -701,40 +692,26 @@ function TrainingGrid({ } return ( - <> - setSelectedEvent(search as unknown as Event)} - setInputFocused={() => {}} - /> - -
- {Object.entries(faceGroups).map(([key, group]) => { - const event = events?.find((ev) => ev.id == key); - return ( - - ); - })} -
- +
+ {Object.entries(faceGroups).map(([key, group]) => { + const event = events?.find((ev) => ev.id == key); + return ( + + ); + })} +
); } @@ -745,7 +722,6 @@ type FaceAttemptGroupProps = { faceNames: string[]; selectedFaces: string[]; onClickFaces: (image: string[], ctrl: boolean) => void; - onSelectEvent: (event: Event) => void; onRefresh: () => void; }; function FaceAttemptGroup({ @@ -755,7 +731,6 @@ function FaceAttemptGroup({ faceNames, selectedFaces, onClickFaces, - onSelectEvent, onRefresh, }: FaceAttemptGroupProps) { const { t } = useTranslation(["views/faceLibrary", "views/explore"]); @@ -773,8 +748,8 @@ function FaceAttemptGroup({ const handleClickEvent = useCallback( (meta: boolean) => { - if (event && selectedFaces.length == 0 && !meta) { - onSelectEvent(event); + if (!meta) { + return; } else { const anySelected = group.find((face) => selectedFaces.includes(face.filename)) != @@ -798,7 +773,7 @@ function FaceAttemptGroup({ } } }, - [event, group, selectedFaces, onClickFaces, onSelectEvent], + [group, selectedFaces, onClickFaces], ); // api calls @@ -873,7 +848,6 @@ function FaceAttemptGroup({ handleClickEvent(true); } }} - onSelectEvent={onSelectEvent} > {(data) => ( <>