Implement full deletion

This commit is contained in:
Nicolas Mowen 2025-06-04 06:34:01 -06:00
parent 234c65b5c3
commit b3cee44f06
2 changed files with 187 additions and 21 deletions

View File

@ -2,7 +2,8 @@
"button": { "button": {
"deleteClassificationAttempts": "Delete Classification Images", "deleteClassificationAttempts": "Delete Classification Images",
"renameCategory": "Rename Category", "renameCategory": "Rename Category",
"deleteCategory": "Rename Category" "deleteCategory": "Rename Category",
"deleteImages": "Delete Images"
}, },
"toast": { "toast": {
"success": { "success": {
@ -18,12 +19,20 @@
"title": "Delete Category", "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." "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": { "renameCategory": {
"title": "Rename Category", "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." "desc": "Enter a new name for {{name}}. You will be required to retrain the model for the name change to take affect."
}, },
"description": { "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": { "train": {
"title": "Train", "title": "Train",

View File

@ -1,6 +1,16 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -21,14 +31,15 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CustomClassificationModelConfig } from "@/types/frigateConfig"; import { CustomClassificationModelConfig } from "@/types/frigateConfig";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import axios from "axios"; import axios from "axios";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { LuPencil, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
@ -50,12 +61,51 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
[id: string]: string[]; [id: string]: string[];
}>(`classification/${model.name}/dataset`); }>(`classification/${model.name}/dataset`);
// image multiselect
const [selectedImages, setSelectedImages] = useState<string[]>([]);
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 // actions
const trainModel = useCallback(() => { const trainModel = useCallback(() => {
axios.post(`classification/${model.name}/train`); axios.post(`classification/${model.name}/train`);
}, [model]); }, [model]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<string[] | null>(
null,
);
const onDelete = useCallback( const onDelete = useCallback(
(ids: string[], isName: boolean = false) => { (ids: string[], isName: boolean = false) => {
const api = const api =
@ -66,7 +116,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
axios axios
.post(api, { ids }) .post(api, { ids })
.then((resp) => { .then((resp) => {
//setSelectedFaces([]); setSelectedImages([]);
if (resp.status == 200) { if (resp.status == 200) {
if (isName) { if (isName) {
@ -114,11 +164,85 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
[pageToggle, model, refreshTrain, refreshDataset, t], [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 ( return (
<div className="flex size-full flex-col overflow-hidden p-2"> <div className="flex size-full flex-col overflow-hidden">
<Toaster /> <Toaster />
<div className="mb-2 flex flex-row justify-between gap-2 align-middle"> <AlertDialog
open={!!deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(
pageToggle == "train"
? "deleteTrainImages.title"
: "deleteDatasetImages.title",
)}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans
ns="views/classificationModel"
values={{ count: deleteDialogOpen?.length, dataset: pageToggle }}
>
{pageToggle == "train"
? "deleteTrainImages.desc"
: "deleteDatasetImages.desc"}
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={() => {
if (deleteDialogOpen) {
onDelete(deleteDialogOpen);
setDeleteDialogOpen(null);
}
}}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="flex flex-row justify-between gap-2 px-2 pt-2 align-middle">
<LibrarySelector <LibrarySelector
pageToggle={pageToggle} pageToggle={pageToggle}
dataset={dataset || {}} dataset={dataset || {}}
@ -127,14 +251,36 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
onDelete={onDelete} onDelete={onDelete}
onRename={() => {}} onRename={() => {}}
/> />
<Button onClick={trainModel}>Train Model</Button> {selectedImages?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedImages.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() => setSelectedImages([])}
>
{t("button.unselect", { ns: "common" })}
</div>
</div>
<Button
className="flex gap-2"
onClick={() => setDeleteDialogOpen(selectedImages)}
>
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.deleteImages")}
</Button>
</div>
) : (
<Button onClick={trainModel}>Train Model</Button>
)}
</div> </div>
{pageToggle == "train" ? ( {pageToggle == "train" ? (
<TrainGrid <TrainGrid
model={model} model={model}
trainImages={trainImages || []} trainImages={trainImages || []}
selected={false} selectedImages={selectedImages}
onClickImages={() => {}} onClickImages={onClickImages}
onDelete={onDelete} onDelete={onDelete}
/> />
) : ( ) : (
@ -142,6 +288,8 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
modelName={model.name} modelName={model.name}
categoryName={pageToggle} categoryName={pageToggle}
images={dataset?.[pageToggle] || []} images={dataset?.[pageToggle] || []}
selectedImages={selectedImages}
onClickImages={onClickImages}
onDelete={onDelete} onDelete={onDelete}
/> />
)} )}
@ -338,27 +486,36 @@ type DatasetGridProps = {
modelName: string; modelName: string;
categoryName: string; categoryName: string;
images: string[]; images: string[];
selectedImages: string[];
onClickImages: (images: string[], ctrl: boolean) => void;
onDelete: (ids: string[]) => void; onDelete: (ids: string[]) => void;
}; };
function DatasetGrid({ function DatasetGrid({
modelName, modelName,
categoryName, categoryName,
images, images,
selectedImages,
onClickImages,
onDelete, onDelete,
}: DatasetGridProps) { }: DatasetGridProps) {
const { t } = useTranslation(["views/classificationModel"]); const { t } = useTranslation(["views/classificationModel"]);
return ( return (
<div className="grid grid-cols-10 gap-2 overflow-y-auto"> <div className="grid grid-cols-10 gap-2 overflow-y-auto p-2">
{images.map((image) => ( {images.map((image) => (
<div <div
className={cn( className={cn(
"flex h-60 cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]", "flex h-60 cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
"outline-transparent duration-500", selectedImages.includes(image)
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)} )}
onClick={() => { onClick={(e) => {
//e.stopPropagation(); e.stopPropagation();
//onClickImages([data.raw], e.ctrlKey || e.metaKey);
if (e.ctrlKey || e.metaKey) {
onClickImages([image], true);
}
}} }}
> >
<div <div
@ -401,14 +558,14 @@ function DatasetGrid({
type TrainGridProps = { type TrainGridProps = {
model: CustomClassificationModelConfig; model: CustomClassificationModelConfig;
trainImages: string[]; trainImages: string[];
selected: boolean; selectedImages: string[];
onClickImages: (images: string[], ctrl: boolean) => void; onClickImages: (images: string[], ctrl: boolean) => void;
onDelete: (ids: string[]) => void; onDelete: (ids: string[]) => void;
}; };
function TrainGrid({ function TrainGrid({
model, model,
trainImages, trainImages,
selected, selectedImages,
onClickImages, onClickImages,
onDelete, onDelete,
}: TrainGridProps) { }: TrainGridProps) {
@ -429,13 +586,13 @@ function TrainGrid({
); );
return ( return (
<div className="grid grid-cols-10 gap-2 overflow-y-auto"> <div className="grid grid-cols-10 gap-2 overflow-y-auto p-2">
{trainData?.map((data) => ( {trainData?.map((data) => (
<div <div
key={data.timestamp} key={data.timestamp}
className={cn( className={cn(
"flex cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]", "flex cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
selected selectedImages.includes(data.raw)
? "shadow-selected outline-selected" ? "shadow-selected outline-selected"
: "outline-transparent duration-500", : "outline-transparent duration-500",
)} )}