From e6b3b8a6934058487766d5b530c4e3a54c54996c Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 7 Oct 2025 08:14:20 -0600 Subject: [PATCH] Refactor grouped face card into generic component --- .../components/card/ClassificationCard.tsx | 140 ++++++++++++++++- web/src/pages/FaceLibrary.tsx | 147 ++++-------------- .../classification/ModelTrainingView.tsx | 76 +++++++++ 3 files changed, 247 insertions(+), 116 deletions(-) diff --git a/web/src/components/card/ClassificationCard.tsx b/web/src/components/card/ClassificationCard.tsx index 896ad16f4..e0f1385cc 100644 --- a/web/src/components/card/ClassificationCard.tsx +++ b/web/src/components/card/ClassificationCard.tsx @@ -5,9 +5,15 @@ import { ClassificationItemData, ClassificationThreshold, } from "@/types/classification"; +import { Event } from "@/types/event"; import { useMemo, useRef, useState } from "react"; -import { isMobile } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; +import TimeAgo from "../dynamic/TimeAgo"; +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"; type ClassificationCardProps = { className?: string; @@ -120,3 +126,135 @@ export function ClassificationCard({ ); } + +type GroupedClassificationCardProps = { + group: ClassificationItemData[]; + event?: Event; + threshold?: ClassificationThreshold; + selectedItems: string[]; + i18nLibrary: string; + onClick: (data: ClassificationItemData | undefined) => void; + onSelectEvent: (event: Event) => void; + children?: (data: ClassificationItemData) => React.ReactNode; +}; +export function GroupedClassificationCard({ + group, + event, + threshold, + selectedItems, + i18nLibrary, + onClick, + onSelectEvent, + children, +}: GroupedClassificationCardProps) { + const navigate = useNavigate(); + const { t } = useTranslation(["views/explore", i18nLibrary]); + + // data + + const allItemsSelected = useMemo( + () => group.every((data) => selectedItems.includes(data.filename)), + [group, selectedItems], + ); + + const time = useMemo(() => { + const item = group[0]; + + if (!item?.timestamp) { + return undefined; + } + + return item.timestamp * 1000; + }, [group]); + + return ( +
{ + if (selectedItems.length) { + onClick(undefined); + } + }} + onContextMenu={(e) => { + e.stopPropagation(); + e.preventDefault(); + onClick(undefined); + }} + > +
+
+
+ {t("details.person")} + {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", + })} + + +
+ )} +
+ +
+ {group.map((data: ClassificationItemData) => ( + { + if (meta || selectedItems.length > 0) { + onClick(data); + } else if (event) { + onSelectEvent(event); + } + }} + > + {children?.(data)} + + ))} +
+
+ ); +} diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 497a32f5a..020b3b664 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -1,4 +1,3 @@ -import TimeAgo from "@/components/dynamic/TimeAgo"; import AddFaceIcon from "@/components/icons/AddFaceIcon"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog"; @@ -52,7 +51,7 @@ import { useRef, useState, } from "react"; -import { isDesktop, isMobile } from "react-device-detect"; +import { isDesktop } from "react-device-detect"; import { Trans, useTranslation } from "react-i18next"; import { LuFolderCheck, @@ -60,17 +59,18 @@ import { LuPencil, LuRefreshCw, LuScanFace, - LuSearch, LuTrash2, } from "react-icons/lu"; -import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; import SearchDetailDialog, { SearchTab, } from "@/components/overlay/detail/SearchDetailDialog"; import { SearchResult } from "@/types/search"; -import { ClassificationCard } from "@/components/card/ClassificationCard"; +import { + ClassificationCard, + GroupedClassificationCard, +} from "@/components/card/ClassificationCard"; import { ClassificationItemData } from "@/types/classification"; export default function FaceLibrary() { @@ -758,16 +758,10 @@ function FaceAttemptGroup({ onSelectEvent, onRefresh, }: FaceAttemptGroupProps) { - const navigate = useNavigate(); const { t } = useTranslation(["views/faceLibrary", "views/explore"]); // data - const allFacesSelected = useMemo( - () => group.every((face) => selectedFaces.includes(face.filename)), - [group, selectedFaces], - ); - const threshold = useMemo(() => { return { recognition: config.face_recognition.recognition_threshold, @@ -775,16 +769,6 @@ function FaceAttemptGroup({ }; }, [config]); - const time = useMemo(() => { - const item = group[0]; - - if (!item?.timestamp) { - return undefined; - } - - return item.timestamp * 1000; - }, [group]); - // interaction const handleClickEvent = useCallback( @@ -875,108 +859,41 @@ function FaceAttemptGroup({ ); return ( -
{ - if (selectedFaces.length) { + { + if (data) { + onClickFaces([data.filename], true); + } else { handleClickEvent(true); } }} - onContextMenu={(e) => { - e.stopPropagation(); - e.preventDefault(); - handleClickEvent(true); - }} + onSelectEvent={onSelectEvent} > -
-
-
- {t("details.person")} - {event?.sub_label - ? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)` - : ": " + t("details.unknown")} -
- {time && ( - - )} -
- {event && ( + {(data) => ( + <> + onTrainAttempt(data, name)} + > + + -
{ - navigate(`/explore?event_id=${event.id}`); - }} - > - -
+ onReprocess(data)} + />
- - - {t("details.item.button.viewInExplore", { - ns: "views/explore", - })} - - + {t("button.reprocessFace")}
- )} -
- -
- {group.map((data: ClassificationItemData) => ( - { - if (meta || selectedFaces.length > 0) { - onClickFaces([data.filename], true); - } else if (event) { - onSelectEvent(event); - } - }} - > - onTrainAttempt(data, name)} - > - - - - - onReprocess(data)} - /> - - {t("button.reprocessFace")} - - - ))} -
-
+ + )} + ); } diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index afe4a225e..8519ba82c 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -818,3 +818,79 @@ function StateTrainGrid({ ); } + +type ObjectTrainGridProps = { + model: CustomClassificationModelConfig; + contentRef: MutableRefObject; + classes: string[]; + trainData?: ClassificationItemData[]; + selectedImages: string[]; + onClickImages: (images: string[], ctrl: boolean) => void; + onRefresh: () => void; + onDelete: (ids: string[]) => void; +}; +function ObjectTrainGrid({ + model, + contentRef, + classes, + trainData, + selectedImages, + onClickImages, + onRefresh, + onDelete, +}: ObjectTrainGridProps) { + const { t } = useTranslation(["views/classificationModel"]); + + const threshold = useMemo(() => { + return { + recognition: model.threshold, + unknown: model.threshold, + }; + }, [model]); + + return ( +
+ {trainData?.map((data) => ( + onClickImages([data.filename], meta)} + > + + + + + + { + e.stopPropagation(); + onDelete([data.filename]); + }} + /> + + + {t("button.deleteClassificationAttempts")} + + + + ))} +
+ ); +}