frigate/web/src/pages/FaceLibrary.tsx
Josh Hawkins 6fdd65ddb5
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
UI tweaks (#23346)
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00

1123 lines
32 KiB
TypeScript

import AddFaceIcon from "@/components/icons/AddFaceIcon";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { EmptyCard } from "@/components/card/EmptyCard";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog";
import { Button, buttonVariants } from "@/components/ui/button";
import BlurredIconButton from "@/components/button/BlurredIconButton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Toaster } from "@/components/ui/sonner";
import {
Tooltip,
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 { Event } from "@/types/event";
import { FaceLibraryData } from "@/types/face";
import { FrigateConfig } from "@/types/frigateConfig";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import axios from "axios";
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { Trans, useTranslation } from "react-i18next";
import {
LuFolderCheck,
LuImagePlus,
LuPencil,
LuRefreshCw,
LuScanFace,
LuTrash2,
} from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
import {
ClassificationCard,
GroupedClassificationCard,
} from "@/components/card/ClassificationCard";
import {
ClassificationItemData,
ClassifiedEvent,
} from "@/types/classification";
export default function FaceLibrary() {
const { t } = useTranslation(["views/faceLibrary"]);
const { data: config } = useSWR<FrigateConfig>("config");
// title
useEffect(() => {
document.title = t("documentTitle");
}, [t]);
const [page, setPage] = useState<string>("train");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
// face data
const { data: faceData, mutate: refreshFaces } =
useSWR<FaceLibraryData>("faces");
const faces = useMemo<string[]>(
() =>
faceData
? Object.keys(faceData)
.filter((face) => face != "train")
.sort()
: [],
[faceData],
);
const faceImages = useMemo<string[]>(
() => (pageToggle && faceData ? faceData[pageToggle] : []),
[pageToggle, faceData],
);
const trainImages = useMemo<string[]>(
() => faceData?.["train"] || [],
[faceData],
);
// upload
const [upload, setUpload] = useState(false);
const [addFace, setAddFace] = useState(false);
// input focus for keyboard shortcuts
const onUploadImage = useCallback(
(file: File) => {
const formData = new FormData();
formData.append("file", file);
axios
.post(`faces/${pageToggle}/register`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((resp) => {
if (resp.status == 200) {
setUpload(false);
refreshFaces();
toast.success(t("toast.success.uploadedImage"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), {
position: "top-center",
});
});
},
[pageToggle, refreshFaces, t],
);
// face multiselect
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
const onClickFaces = useCallback(
(images: string[], ctrl: boolean) => {
if (selectedFaces.length == 0 && !ctrl) {
return;
}
let newSelectedFaces = [...selectedFaces];
images.forEach((imageId) => {
const index = newSelectedFaces.indexOf(imageId);
if (index != -1) {
if (selectedFaces.length == 1) {
newSelectedFaces = [];
} else {
const copy = [
...newSelectedFaces.slice(0, index),
...newSelectedFaces.slice(index + 1),
];
newSelectedFaces = copy;
}
} else {
newSelectedFaces.push(imageId);
}
});
setSelectedFaces(newSelectedFaces);
},
[selectedFaces, setSelectedFaces],
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<{
name: string;
ids: string[];
} | null>(null);
const onDelete = useCallback(
(name: string, ids: string[], isName: boolean = false) => {
axios
.post(`/faces/${name}/delete`, { ids })
.then((resp) => {
setSelectedFaces([]);
if (resp.status == 200) {
if (isName) {
toast.success(
t("toast.success.deletedName", { count: ids.length }),
{
position: "top-center",
},
);
} else {
toast.success(
t("toast.success.deletedFace", { count: ids.length }),
{
position: "top-center",
},
);
}
if (faceImages.length == 1) {
// face has been deleted
setPageToggle("train");
}
refreshFaces();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (isName) {
toast.error(t("toast.error.deleteNameFailed", { errorMessage }), {
position: "top-center",
});
} else {
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
}
});
},
[faceImages, refreshFaces, setPageToggle, t],
);
const onRename = useCallback(
(oldName: string, newName: string) => {
axios
.put(`/faces/${oldName}/rename`, { new_name: newName })
.then((resp) => {
if (resp.status === 200) {
toast.success(t("toast.success.renamedFace", { name: newName }), {
position: "top-center",
});
setPageToggle("train");
refreshFaces();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.renameFaceFailed", { errorMessage }), {
position: "top-center",
});
});
},
[setPageToggle, refreshFaces, t],
);
const onReclassify = useCallback(
(image: string, newName: string) => {
axios
.post(`/faces/${pageToggle}/reclassify`, {
id: image,
new_name: newName,
})
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.reclassifiedFace"), {
position: "top-center",
});
refreshFaces();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.reclassifyFailed", { errorMessage }), {
position: "top-center",
});
});
},
[pageToggle, refreshFaces, t],
);
// keyboard
const contentRef = useRef<HTMLDivElement | null>(null);
useKeyboardListener(
["a", "Escape"],
(key, modifiers) => {
if (!modifiers.down) {
return true;
}
switch (key) {
case "a":
if (modifiers.ctrl && !modifiers.repeat) {
if (selectedFaces.length) {
setSelectedFaces([]);
} else {
setSelectedFaces([
...(pageToggle === "train" ? trainImages : faceImages),
]);
}
return true;
}
break;
case "Escape":
setSelectedFaces([]);
return true;
}
return false;
},
contentRef,
);
useEffect(() => {
setSelectedFaces([]);
}, [pageToggle]);
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="flex size-full flex-col p-2">
<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
open={upload}
title={t("uploadFaceImage.title")}
description={t("uploadFaceImage.desc", { pageToggle })}
setOpen={setUpload}
onSave={onUploadImage}
/>
<CreateFaceWizardDialog
open={addFace}
setOpen={setAddFace}
onFinish={refreshFaces}
/>
<div className="relative mb-2 flex h-11 w-full items-center justify-between">
<LibrarySelector
pageToggle={pageToggle}
faceData={faceData}
faces={faces}
trainImages={trainImages}
setPageToggle={setPageToggle}
onDelete={onDelete}
onRename={onRename}
/>
{selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<div className="mx-1 flex w-auto items-center justify-center text-sm text-muted-foreground">
<div className="p-1">
{t("selected", {
ns: "views/events",
count: selectedFaces.length,
})}
</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() => setSelectedFaces([])}
>
{t("button.unselect", { ns: "common" })}
</div>
{selectedFaces.length <
(pageToggle === "train"
? trainImages.length
: faceImages.length) && (
<>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() =>
setSelectedFaces([
...(pageToggle === "train" ? trainImages : faceImages),
])
}
>
{t("select_all", { ns: "views/events" })}
</div>
</>
)}
</div>
<Button
className="flex gap-2"
onClick={() =>
setDeleteDialogOpen({ name: pageToggle, ids: selectedFaces })
}
>
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.deleteFaceAttempts")}
</Button>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.addFace")}
</Button>
{pageToggle != "train" && (
<Button className="flex gap-2" onClick={() => setUpload(true)}>
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
{isDesktop && t("button.uploadImage")}
</Button>
)}
</div>
)}
</div>
{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" />
{t("nofaces")}
</div>
) : (
pageToggle &&
(pageToggle == "train" ? (
<TrainingGrid
config={config}
contentRef={contentRef}
attemptImages={trainImages}
faceNames={faces}
selectedFaces={selectedFaces}
isLoading={faceData === undefined}
onClickFaces={onClickFaces}
onAddFace={() => setAddFace(true)}
onRefresh={refreshFaces}
/>
) : (
<FaceGrid
contentRef={contentRef}
faceImages={faceImages}
faceNames={faces}
pageToggle={pageToggle}
selectedFaces={selectedFaces}
onClickFaces={onClickFaces}
onDelete={onDelete}
onReclassify={onReclassify}
/>
))
)}
</div>
);
}
type LibrarySelectorProps = {
pageToggle: string | undefined;
faceData?: FaceLibraryData;
faces: string[];
trainImages: string[];
setPageToggle: (toggle: string) => void;
onDelete: (name: string, ids: string[], isName: boolean) => void;
onRename: (old_name: string, new_name: string) => void;
};
function LibrarySelector({
pageToggle,
faceData,
faces,
trainImages,
setPageToggle,
onDelete,
onRename,
}: LibrarySelectorProps) {
const { t } = useTranslation(["views/faceLibrary"]);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [renameFace, setRenameFace] = useState<string | null>(null);
const handleDeleteFace = useCallback(
(faceName: string) => {
// Get all image IDs for this face
const imageIds = faceData?.[faceName] || [];
onDelete(faceName, imageIds, true);
setPageToggle("train");
},
[faceData, onDelete, setPageToggle],
);
const handleSetOpen = useCallback(
(open: boolean) => {
setRenameFace(open ? renameFace : null);
},
[renameFace],
);
const pageTitle = useMemo(() => {
if (pageToggle != "train") {
return pageToggle;
}
if (isMobileOnly) {
return t("train.titleShort");
}
return t("train.title");
}, [pageToggle, t]);
return (
<>
<Dialog
open={!!confirmDelete}
onOpenChange={(open) => !open && setConfirmDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("deleteFaceLibrary.title")}</DialogTitle>
<DialogDescription>
{t("deleteFaceLibrary.desc", { name: confirmDelete })}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="destructive"
onClick={() => {
if (confirmDelete) {
handleDeleteFace(confirmDelete);
setConfirmDelete(null);
}
}}
>
{t("button.delete", { ns: "common" })}
</Button>
</div>
</DialogContent>
</Dialog>
<TextEntryDialog
open={!!renameFace}
setOpen={handleSetOpen}
title={t("renameFace.title")}
description={t("renameFace.desc", { name: renameFace })}
onSave={(newName) => {
onRename(renameFace!, newName);
setRenameFace(null);
}}
defaultValue={renameFace || ""}
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
regexErrorMessage={t("description.invalidName")}
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize">
{pageTitle}
<span className="ml-2 text-primary-variant">
({(pageToggle && faceData?.[pageToggle]?.length) || 0})
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="scrollbar-container max-h-[40dvh] min-w-[220px] overflow-y-auto"
align="start"
>
<DropdownMenuItem
className="flex cursor-pointer items-center justify-start gap-2"
aria-label={t("train.aria")}
onClick={() => setPageToggle("train")}
>
<div>{t("train.title")}</div>
<div className="text-secondary-foreground">
({trainImages.length})
</div>
</DropdownMenuItem>
{trainImages.length > 0 && faces.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="mb-1 ml-1.5 text-xs text-secondary-foreground">
{t("collections")}
</div>
</>
)}
{Object.values(faces).map((face) => (
<DropdownMenuItem
key={face}
className="group flex items-center justify-between p-0"
>
<div
className="flex-grow cursor-pointer px-2 py-1.5"
onClick={() => setPageToggle(face)}
>
{face}
<span className="ml-2 text-muted-foreground">
({faceData?.[face].length})
</span>
</div>
<div className="flex gap-0.5 px-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setRenameFace(face);
}}
>
<LuPencil className="size-4 text-primary" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("button.renameFace")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(face);
}}
>
<LuTrash2 className="size-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("button.deleteFace")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>
);
}
type TrainingGridProps = {
config: FrigateConfig;
contentRef: MutableRefObject<HTMLDivElement | null>;
attemptImages: string[];
faceNames: string[];
selectedFaces: string[];
isLoading: boolean;
onClickFaces: (images: string[], ctrl: boolean) => void;
onAddFace: () => void;
onRefresh: (
data?:
| FaceLibraryData
| Promise<FaceLibraryData>
| ((
currentData: FaceLibraryData | undefined,
) => FaceLibraryData | undefined),
opts?: boolean | { revalidate?: boolean },
) => Promise<FaceLibraryData | undefined>;
};
function TrainingGrid({
config,
contentRef,
attemptImages,
faceNames,
selectedFaces,
isLoading,
onClickFaces,
onAddFace,
onRefresh,
}: TrainingGridProps) {
const { t } = useTranslation(["views/faceLibrary"]);
// face data
const faceGroups = useMemo(() => {
const groups: { [eventId: string]: ClassificationItemData[] } = {};
const faces = attemptImages
.map((image) => {
const parts = image.split("-");
try {
return {
filename: image,
filepath: `clips/faces/train/${image}`,
timestamp: Number.parseFloat(parts[2]),
eventId: `${parts[0]}-${parts[1]}`,
name: parts[3],
score: Number.parseFloat(parts[4]),
};
} catch {
return null;
}
})
.filter((v) => v != null);
faces
.sort((a, b) => a.eventId.localeCompare(b.eventId))
.reverse()
.forEach((face) => {
if (groups[face.eventId]) {
groups[face.eventId].push(face);
} else {
groups[face.eventId] = [face];
}
});
return groups;
}, [attemptImages]);
const eventIdsQuery = useMemo(
() => Object.keys(faceGroups).join(","),
[faceGroups],
);
const { data: events } = useSWR<Event[]>([
"event_ids",
{ ids: eventIdsQuery },
]);
if (attemptImages.length == 0) {
if (isLoading) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 items-center text-center" />
);
}
if (faceNames.length == 0) {
return (
<EmptyCard
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 items-center text-center"
icon={<AddFaceIcon className="size-16" />}
title={t("train.emptyNoLibrary.title")}
description={t("train.emptyNoLibrary.description")}
buttonText={t("button.addFace")}
onClick={onAddFace}
/>
);
}
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" />
{t("train.empty")}
</div>
);
}
return (
<div
ref={contentRef}
className={cn(
"scrollbar-container grid grid-cols-2 gap-3 overflow-y-scroll p-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 3xl:grid-cols-12",
)}
>
{Object.entries(faceGroups).map(([key, group]) => {
const event = events?.find((ev) => ev.id == key);
return (
<div key={key} className="aspect-square w-full">
<FaceAttemptGroup
config={config}
group={group}
event={event}
faceNames={faceNames}
selectedFaces={selectedFaces}
onClickFaces={onClickFaces}
onRefresh={onRefresh}
/>
</div>
);
})}
</div>
);
}
type FaceAttemptGroupProps = {
config: FrigateConfig;
group: ClassificationItemData[];
event?: Event;
faceNames: string[];
selectedFaces: string[];
onClickFaces: (image: string[], ctrl: boolean) => void;
onRefresh: (
data?:
| FaceLibraryData
| Promise<FaceLibraryData>
| ((
currentData: FaceLibraryData | undefined,
) => FaceLibraryData | undefined),
opts?: boolean | { revalidate?: boolean },
) => Promise<FaceLibraryData | undefined>;
};
function FaceAttemptGroup({
config,
group,
event,
faceNames,
selectedFaces,
onClickFaces,
onRefresh,
}: FaceAttemptGroupProps) {
const { t } = useTranslation(["views/faceLibrary", "views/explore"]);
// data
const threshold = useMemo(() => {
return {
recognition: config.face_recognition.recognition_threshold,
unknown: config.face_recognition.unknown_score,
};
}, [config]);
// interaction
const handleClickEvent = useCallback(
(meta: boolean) => {
if (!meta) {
return;
} else {
const anySelected =
group.find((face) => selectedFaces.includes(face.filename)) !=
undefined;
if (anySelected) {
// deselect all
const toDeselect: string[] = [];
group.forEach((face) => {
if (selectedFaces.includes(face.filename)) {
toDeselect.push(face.filename);
}
});
onClickFaces(toDeselect, false);
} else {
// select all
onClickFaces(
group.map((face) => face.filename),
true,
);
}
}
},
[group, selectedFaces, onClickFaces],
);
// api calls
const onTrainAttempt = useCallback(
(data: ClassificationItemData, trainName: string) => {
axios
.post(`/faces/train/${trainName}/classify`, {
training_file: data.filename,
})
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.trainedFace"), {
position: "top-center",
closeButton: true,
});
onRefresh();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.trainFailed", { errorMessage }), {
position: "top-center",
});
});
},
[onRefresh, t],
);
const onReprocess = useCallback(
(data: ClassificationItemData) => {
axios
.post(`/faces/reprocess`, { training_file: data.filename })
.then((resp) => {
if (resp.status == 200 && resp.data?.success) {
const { face_name, score } = resp.data;
const oldFilename = data.filename;
const parts = oldFilename.split("-");
const newFilename = `${parts[0]}-${parts[1]}-${parts[2]}-${face_name}-${score}.webp`;
onRefresh(
(currentData: FaceLibraryData | undefined) => {
if (!currentData?.train) return currentData;
return {
...currentData,
train: currentData.train.map((filename: string) =>
filename === oldFilename ? newFilename : filename,
),
};
},
{ revalidate: true },
);
toast.success(
t("toast.success.updatedFaceScore", {
name: face_name,
score: score.toFixed(2),
}),
{
position: "top-center",
},
);
} else if (resp.data?.success === false) {
// Handle case where API returns success: false
const errorMessage = resp.data?.message || "Unknown error";
toast.error(
t("toast.error.updateFaceScoreFailed", { errorMessage }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.error.updateFaceScoreFailed", { errorMessage }),
{
position: "top-center",
},
);
});
},
[onRefresh, t],
);
const classifiedEvent: ClassifiedEvent | undefined = useMemo(() => {
if (!event) {
return undefined;
}
return {
id: event.id,
label: event.sub_label,
score: event.data?.sub_label_score,
};
}, [event]);
return (
<GroupedClassificationCard
group={group}
classifiedEvent={classifiedEvent}
threshold={threshold}
selectedItems={selectedFaces}
i18nLibrary="views/faceLibrary"
objectType="person"
noClassificationLabel="details.unknown"
onClick={(data) => {
if (data) {
onClickFaces([data.filename], true);
} else {
handleClickEvent(true);
}
}}
>
{(data) => (
<>
<FaceSelectionDialog
faceNames={faceNames}
onTrainAttempt={(name) => onTrainAttempt(data, name)}
>
<BlurredIconButton>
<AddFaceIcon className="size-5" />
</BlurredIconButton>
</FaceSelectionDialog>
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton onClick={() => onReprocess(data)}>
<LuRefreshCw className="size-5" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("button.reprocessFace")}</TooltipContent>
</Tooltip>
</>
)}
</GroupedClassificationCard>
);
}
type FaceGridProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
faceImages: string[];
faceNames: string[];
pageToggle: string;
selectedFaces: string[];
onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void;
onReclassify: (image: string, newName: string) => void;
};
function FaceGrid({
contentRef,
faceImages,
faceNames,
pageToggle,
selectedFaces,
onClickFaces,
onDelete,
onReclassify,
}: FaceGridProps) {
const { t } = useTranslation(["views/faceLibrary"]);
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" />
{t("nofaces")}
</div>
);
}
return (
<div
ref={contentRef}
className={cn(
"scrollbar-container grid grid-cols-2 gap-2 overflow-y-scroll p-1 md:grid-cols-4 xl:grid-cols-8 2xl:grid-cols-10 3xl:grid-cols-12",
)}
>
{sortedFaces.map((image: string) => (
<div key={image} className="aspect-square w-full">
<ClassificationCard
data={{
name: pageToggle,
filename: image,
filepath: `clips/faces/${pageToggle}/${image}`,
}}
selected={selectedFaces.includes(image)}
clickable={selectedFaces.length > 0}
i18nLibrary="views/faceLibrary"
onClick={(data, meta) => onClickFaces([data.filename], meta)}
>
<FaceSelectionDialog
faceNames={faceNames}
excludeName={pageToggle}
dialogLabel={t("reclassifyFaceAs")}
tooltipLabel={t("reclassifyFace")}
onTrainAttempt={(newName) => onReclassify(image, newName)}
>
<BlurredIconButton>
<AddFaceIcon className="size-5" />
</BlurredIconButton>
</FaceSelectionDialog>
<Tooltip>
<TooltipTrigger>
<LuTrash2
className="size-5 cursor-pointer text-gray-200 hover:text-danger"
onClick={(e) => {
e.stopPropagation();
onDelete(pageToggle, [image]);
}}
/>
</TooltipTrigger>
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>
</Tooltip>
</ClassificationCard>
</div>
))}
</div>
);
}