Files
frigate/web/src/components/overlay/ClassificationSelectionDialog.tsx
T

197 lines
5.9 KiB
TypeScript
Raw Normal View History

2025-06-04 17:09:55 -06:00
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import TextEntryDialog from "./dialog/TextEntryDialog";
import { Button } from "../ui/button";
import axios from "axios";
import { toast } from "sonner";
2025-10-22 07:36:09 -06:00
import { Separator } from "../ui/separator";
2025-06-04 17:09:55 -06:00
type ClassificationSelectionDialogProps = {
className?: string;
classes: string[];
modelName: string;
image: string;
2026-03-24 08:18:06 -05:00
onRefresh?: () => void;
onCategorize?: (category: string) => void;
excludeCategory?: string;
dialogLabel?: string;
tooltipLabel?: string;
2025-06-04 17:09:55 -06:00
children: ReactNode;
};
export default function ClassificationSelectionDialog({
className,
classes,
modelName,
image,
onRefresh,
2026-03-24 08:18:06 -05:00
onCategorize,
excludeCategory,
dialogLabel,
tooltipLabel,
2025-06-04 17:09:55 -06:00
children,
}: ClassificationSelectionDialogProps) {
const { t } = useTranslation(["views/classificationModel"]);
const onCategorizeImage = useCallback(
(category: string) => {
2026-03-24 08:18:06 -05:00
if (onCategorize) {
onCategorize(category);
return;
}
2025-06-04 17:09:55 -06:00
axios
.post(`/classification/${modelName}/dataset/categorize`, {
category,
training_file: image,
})
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.categorizedImage"), {
position: "top-center",
});
2026-03-24 08:18:06 -05:00
onRefresh?.();
2025-06-04 17:09:55 -06:00
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.categorizeFailed", { errorMessage }), {
position: "top-center",
});
});
},
2026-03-24 08:18:06 -05:00
[modelName, image, onRefresh, onCategorize, t],
);
const filteredClasses = useMemo(
() =>
excludeCategory ? classes.filter((c) => c !== excludeCategory) : classes,
[classes, excludeCategory],
2025-06-04 17:09:55 -06:00
);
const isChildButton = useMemo(
() => React.isValidElement(children) && children.type === Button,
[children],
);
// control
2025-06-27 06:28:40 -06:00
const [newClass, setNewClass] = useState(false);
2025-06-04 17:09:55 -06:00
2026-05-13 11:04:11 -05:00
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
2025-06-04 17:09:55 -06:00
// components
const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const SelectorContent = isDesktop ? DropdownMenuContent : DrawerContent;
const SelectorItem = isDesktop
? DropdownMenuItem
: (props: React.HTMLAttributes<HTMLDivElement>) => (
<DrawerClose asChild>
<div {...props} className={cn(props.className, "my-2")} />
</DrawerClose>
);
2026-05-13 11:04:11 -05:00
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
2025-06-04 17:09:55 -06:00
return (
2025-10-22 07:36:09 -06:00
<div className={className ?? "flex"}>
2025-11-07 07:53:27 -06:00
<TextEntryDialog
open={newClass}
setOpen={setNewClass}
title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)}
/>
2026-05-13 11:04:11 -05:00
<Selector {...(isDesktop ? { modal: false } : {})}>
<Tooltip>
<TooltipTrigger asChild={isChildButton}>
<SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>
{tooltipLabel ?? t("categorizeImage")}
</TooltipContent>
</Tooltip>
<SelectorContent
ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
2025-06-04 17:09:55 -06:00
>
2026-05-13 11:04:11 -05:00
{t("createCategory.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
2025-06-04 17:09:55 -06:00
</div>
);
}