improve face library deletion

- add a confirmation dialog
- add ability to select all / delete faces in collections
This commit is contained in:
Josh Hawkins 2025-05-08 12:27:30 -05:00
parent 644e36fb12
commit f639fa82ed
2 changed files with 154 additions and 38 deletions

View File

@ -40,12 +40,17 @@
"title": "Delete Name", "title": "Delete Name",
"desc": "Are you sure you want to delete the collection {{name}}? This will permanently delete all associated faces." "desc": "Are you sure you want to delete the collection {{name}}? This will permanently delete all associated faces."
}, },
"deleteFaceAttempts": {
"title": "Delete Faces",
"desc_one": "Are you sure you want to delete {{count}} face? This action cannot be undone.",
"desc_other": "Are you sure you want to delete {{count}} faces? This action cannot be undone."
},
"renameFace": { "renameFace": {
"title": "Rename Face", "title": "Rename Face",
"desc": "Enter a new name for {{name}}" "desc": "Enter a new name for {{name}}"
}, },
"button": { "button": {
"deleteFaceAttempts": "Delete Face Attempts", "deleteFaceAttempts": "Delete Faces",
"addFace": "Add Face", "addFace": "Add Face",
"renameFace": "Rename Face", "renameFace": "Rename Face",
"deleteFace": "Delete Face", "deleteFace": "Delete Face",

View File

@ -6,7 +6,17 @@ import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizard
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog"; import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog";
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,
@ -44,7 +54,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { import {
LuFolderCheck, LuFolderCheck,
LuImagePlus, LuImagePlus,
@ -165,6 +175,11 @@ export default function FaceLibrary() {
[selectedFaces, setSelectedFaces], [selectedFaces, setSelectedFaces],
); );
const [deleteDialogOpen, setDeleteDialogOpen] = useState<{
name: string;
ids: string[];
} | null>(null);
const onDelete = useCallback( const onDelete = useCallback(
(name: string, ids: string[], isName: boolean = false) => { (name: string, ids: string[], isName: boolean = false) => {
axios axios
@ -191,7 +206,7 @@ export default function FaceLibrary() {
if (faceImages.length == 1) { if (faceImages.length == 1) {
// face has been deleted // face has been deleted
setPageToggle(""); setPageToggle("train");
} }
refreshFaces(); refreshFaces();
@ -244,29 +259,32 @@ export default function FaceLibrary() {
// keyboard // keyboard
useKeyboardListener( useKeyboardListener(["a", "Escape"], (key, modifiers) => {
page === "train" ? ["a", "Escape"] : [], if (modifiers.repeat || !modifiers.down) {
(key, modifiers) => { return;
if (modifiers.repeat || !modifiers.down) { }
return;
}
switch (key) { switch (key) {
case "a": case "a":
if (modifiers.ctrl) { if (modifiers.ctrl) {
if (selectedFaces.length) { if (selectedFaces.length) {
setSelectedFaces([]); setSelectedFaces([]);
} else { } else {
setSelectedFaces([...trainImages]); setSelectedFaces([
} ...(pageToggle === "train" ? trainImages : faceImages),
]);
} }
break; }
case "Escape": break;
setSelectedFaces([]); case "Escape":
break; setSelectedFaces([]);
} break;
}, }
); });
useEffect(() => {
setSelectedFaces([]);
}, [pageToggle]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
@ -276,6 +294,41 @@ export default function FaceLibrary() {
<div className="flex size-full flex-col p-2"> <div className="flex size-full flex-col p-2">
<Toaster /> <Toaster />
<AlertDialog
open={!!deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteFaceAttempts.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
<Trans
ns="views/faceLibrary"
values={{ count: deleteDialogOpen?.ids.length }}
>
deleteFaceAttempts.desc
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={() => {
if (deleteDialogOpen) {
onDelete(deleteDialogOpen.name, deleteDialogOpen.ids);
setDeleteDialogOpen(null);
}
}}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<UploadImageDialog <UploadImageDialog
open={upload} open={upload}
title={t("uploadFaceImage.title")} title={t("uploadFaceImage.title")}
@ -314,7 +367,9 @@ export default function FaceLibrary() {
</div> </div>
<Button <Button
className="flex gap-2" className="flex gap-2"
onClick={() => onDelete("train", selectedFaces)} onClick={() =>
setDeleteDialogOpen({ name: pageToggle, ids: selectedFaces })
}
> >
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" /> <LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.deleteFaceAttempts")} {isDesktop && t("button.deleteFaceAttempts")}
@ -335,7 +390,13 @@ export default function FaceLibrary() {
</div> </div>
)} )}
</div> </div>
{pageToggle && {pageToggle && faceImages.length === 0 && pageToggle !== "train" ? (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />
No faces available
</div>
) : (
pageToggle &&
(pageToggle == "train" ? ( (pageToggle == "train" ? (
<TrainingGrid <TrainingGrid
config={config} config={config}
@ -349,9 +410,12 @@ export default function FaceLibrary() {
<FaceGrid <FaceGrid
faceImages={faceImages} faceImages={faceImages}
pageToggle={pageToggle} pageToggle={pageToggle}
selectedFaces={selectedFaces}
onClickFaces={onClickFaces}
onDelete={onDelete} onDelete={onDelete}
/> />
))} ))
)}
</div> </div>
); );
} }
@ -1009,16 +1073,36 @@ function FaceAttempt({
type FaceGridProps = { type FaceGridProps = {
faceImages: string[]; faceImages: string[];
pageToggle: string; pageToggle: string;
selectedFaces: string[];
onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void; onDelete: (name: string, ids: string[]) => void;
}; };
function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) { function FaceGrid({
const sortedFaces = useMemo(() => faceImages.sort().reverse(), [faceImages]); faceImages,
pageToggle,
selectedFaces,
onClickFaces,
onDelete,
}: FaceGridProps) {
const sortedFaces = useMemo(
() => (faceImages || []).sort().reverse(),
[faceImages],
);
if (sortedFaces.length === 0) {
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />
No faces available
</div>
);
}
return ( return (
<div <div
className={cn( className={cn(
"scrollbar-container gap-2 overflow-y-scroll", "scrollbar-container gap-2 overflow-y-scroll p-1",
isDesktop ? "flex flex-wrap" : "grid grid-cols-2", isDesktop ? "flex flex-wrap" : "grid grid-cols-2 md:grid-cols-4",
)} )}
> >
{sortedFaces.map((image: string) => ( {sortedFaces.map((image: string) => (
@ -1026,6 +1110,8 @@ function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
key={image} key={image}
name={pageToggle} name={pageToggle}
image={image} image={image}
selected={selectedFaces.includes(image)}
onClickFaces={onClickFaces}
onDelete={onDelete} onDelete={onDelete}
/> />
))} ))}
@ -1036,22 +1122,44 @@ function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) {
type FaceImageProps = { type FaceImageProps = {
name: string; name: string;
image: string; image: string;
selected: boolean;
onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void; onDelete: (name: string, ids: string[]) => void;
}; };
function FaceImage({ name, image, onDelete }: FaceImageProps) { function FaceImage({
name,
image,
selected,
onClickFaces,
onDelete,
}: FaceImageProps) {
const { t } = useTranslation(["views/faceLibrary"]); const { t } = useTranslation(["views/faceLibrary"]);
return ( return (
<div className="relative flex flex-col rounded-lg"> <div
className={cn(
"flex cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
selected
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
onClick={(e) => {
e.stopPropagation();
onClickFaces([image], e.ctrlKey || e.metaKey);
}}
>
<div <div
className={cn( className={cn(
"w-full overflow-hidden rounded-t-lg *:text-card-foreground", "w-full overflow-hidden p-2 *:text-card-foreground",
isMobile && "flex justify-center", isMobile && "flex justify-center",
)} )}
> >
<img className="h-40" src={`${baseUrl}clips/faces/${name}/${image}`} /> <img
className="h-40 rounded-lg"
src={`${baseUrl}clips/faces/${name}/${image}`}
/>
</div> </div>
<div className="rounded-b-lg bg-card p-2"> <div className="rounded-b-lg bg-card p-3">
<div className="flex w-full flex-row items-center justify-between gap-2"> <div className="flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start text-xs text-primary-variant"> <div className="flex flex-col items-start text-xs text-primary-variant">
<div className="smart-capitalize">{name}</div> <div className="smart-capitalize">{name}</div>
@ -1061,7 +1169,10 @@ function FaceImage({ name, image, onDelete }: FaceImageProps) {
<TooltipTrigger> <TooltipTrigger>
<LuTrash2 <LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-primary" className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => onDelete(name, [image])} onClick={(e) => {
e.stopPropagation();
onDelete(name, [image]);
}}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent> <TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>