From b3cee44f069761d9c7c9c04457ca8d90793cb313 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 4 Jun 2025 06:34:01 -0600 Subject: [PATCH] Implement full deletion --- .../locales/en/views/classificationModel.json | 13 +- .../classification/ModelTrainingView.tsx | 195 ++++++++++++++++-- 2 files changed, 187 insertions(+), 21 deletions(-) diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index 2d666a69f..f921f0045 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -2,7 +2,8 @@ "button": { "deleteClassificationAttempts": "Delete Classification Images", "renameCategory": "Rename Category", - "deleteCategory": "Rename Category" + "deleteCategory": "Rename Category", + "deleteImages": "Delete Images" }, "toast": { "success": { @@ -18,12 +19,20 @@ "title": "Delete Category", "desc": "Are you sure you want to delete the category {{name}}? This will permanently delete all associated images and require re-training the model." }, + "deleteDatasetImages": { + "title": "Delete Dataset Images", + "desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model." + }, + "deleteTrainImages": { + "title": "Delete Train Images", + "desc": "Are you sure you want to delete {{count}} images? This action cannot be undone." + }, "renameCategory": { "title": "Rename Category", "desc": "Enter a new name for {{name}}. You will be required to retrain the model for the name change to take affect." }, "description": { - "invalidName": " \"invalidName\": \"Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens.\"" + "invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens." }, "train": { "title": "Train", diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index 02ae761c8..14254b6d8 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -1,6 +1,16 @@ import { baseUrl } from "@/api/baseUrl"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, @@ -21,14 +31,15 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { cn } from "@/lib/utils"; import { CustomClassificationModelConfig } from "@/types/frigateConfig"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import axios from "axios"; -import { useCallback, useMemo, useState } from "react"; -import { isMobile } from "react-device-detect"; -import { useTranslation } from "react-i18next"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { isDesktop, isMobile } from "react-device-detect"; +import { Trans, useTranslation } from "react-i18next"; import { LuPencil, LuTrash2 } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; @@ -50,12 +61,51 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { [id: string]: string[]; }>(`classification/${model.name}/dataset`); + // image multiselect + + const [selectedImages, setSelectedImages] = useState([]); + + const onClickImages = useCallback( + (images: string[], ctrl: boolean) => { + if (selectedImages.length == 0 && !ctrl) { + return; + } + + let newSelectedImages = [...selectedImages]; + + images.forEach((imageId) => { + const index = newSelectedImages.indexOf(imageId); + + if (index != -1) { + if (selectedImages.length == 1) { + newSelectedImages = []; + } else { + const copy = [ + ...newSelectedImages.slice(0, index), + ...newSelectedImages.slice(index + 1), + ]; + newSelectedImages = copy; + } + } else { + newSelectedImages.push(imageId); + } + }); + + setSelectedImages(newSelectedImages); + }, + [selectedImages, setSelectedImages], + ); + // actions const trainModel = useCallback(() => { axios.post(`classification/${model.name}/train`); }, [model]); + const [deleteDialogOpen, setDeleteDialogOpen] = useState( + null, + ); + const onDelete = useCallback( (ids: string[], isName: boolean = false) => { const api = @@ -66,7 +116,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { axios .post(api, { ids }) .then((resp) => { - //setSelectedFaces([]); + setSelectedImages([]); if (resp.status == 200) { if (isName) { @@ -114,11 +164,85 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { [pageToggle, model, refreshTrain, refreshDataset, t], ); + // keyboard + + useKeyboardListener(["a", "Escape"], (key, modifiers) => { + if (modifiers.repeat || !modifiers.down) { + return; + } + + switch (key) { + case "a": + if (modifiers.ctrl) { + if (selectedImages.length) { + setSelectedImages([]); + } else { + setSelectedImages([ + ...(pageToggle === "train" + ? trainImages || [] + : dataset?.[pageToggle] || []), + ]); + } + } + break; + case "Escape": + setSelectedImages([]); + break; + } + }); + + useEffect(() => { + setSelectedImages([]); + }, [pageToggle]); + return ( -
+
-
+ setDeleteDialogOpen(null)} + > + + + + {t( + pageToggle == "train" + ? "deleteTrainImages.title" + : "deleteDatasetImages.title", + )} + + + + + {pageToggle == "train" + ? "deleteTrainImages.desc" + : "deleteDatasetImages.desc"} + + + + + {t("button.cancel", { ns: "common" })} + + { + if (deleteDialogOpen) { + onDelete(deleteDialogOpen); + setDeleteDialogOpen(null); + } + }} + > + {t("button.delete", { ns: "common" })} + + + + + +
{}} /> - + {selectedImages?.length > 0 ? ( +
+
+
{`${selectedImages.length} selected`}
+
{"|"}
+
setSelectedImages([])} + > + {t("button.unselect", { ns: "common" })} +
+
+ +
+ ) : ( + + )}
{pageToggle == "train" ? ( {}} + selectedImages={selectedImages} + onClickImages={onClickImages} onDelete={onDelete} /> ) : ( @@ -142,6 +288,8 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { modelName={model.name} categoryName={pageToggle} images={dataset?.[pageToggle] || []} + selectedImages={selectedImages} + onClickImages={onClickImages} onDelete={onDelete} /> )} @@ -338,27 +486,36 @@ type DatasetGridProps = { modelName: string; categoryName: string; images: string[]; + selectedImages: string[]; + onClickImages: (images: string[], ctrl: boolean) => void; onDelete: (ids: string[]) => void; }; function DatasetGrid({ modelName, categoryName, images, + selectedImages, + onClickImages, onDelete, }: DatasetGridProps) { const { t } = useTranslation(["views/classificationModel"]); return ( -
+
{images.map((image) => (
{ - //e.stopPropagation(); - //onClickImages([data.raw], e.ctrlKey || e.metaKey); + onClick={(e) => { + e.stopPropagation(); + + if (e.ctrlKey || e.metaKey) { + onClickImages([image], true); + } }} >
void; onDelete: (ids: string[]) => void; }; function TrainGrid({ model, trainImages, - selected, + selectedImages, onClickImages, onDelete, }: TrainGridProps) { @@ -429,13 +586,13 @@ function TrainGrid({ ); return ( -
+
{trainData?.map((data) => (