Compare commits

..

2 Commits

Author SHA1 Message Date
Josh Hawkins
36fb27ef56
Refactor Tracked Object Details dialog (#20748)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* detail stream settings

* remove old review detail dialog

* change layout

* use detail stream in tracking details

* reusable tabs component

* pass in tabs for desktop

* fix object selection and time updating

* i18n

* aspect fixes

* include tolerance for displaying of path and zone

some browsers (firefox and probably brave) intentionally reduce precision of seeking with currentTime for privacy reasons

* detail stream seeking fixes

* tracking details seeking fixes

* layout tweaks

* add download button back for now

* remove

* remove

* snapshot is now default tab
2025-11-01 09:19:30 -05:00
Nicolas Mowen
9937a7cc3d
Add ability to delete classification models (#20747)
* fix typo

* Add ability to delete classification models
2025-11-01 09:11:24 -05:00
14 changed files with 948 additions and 1409 deletions

View File

@ -804,3 +804,42 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
content={"success": True, "message": "Example generation completed"},
status_code=200,
)
@router.delete(
"/classification/{name}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete a classification model",
description="""Deletes a specific classification model and all its associated data.
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
)
def delete_classification_model(request: Request, name: str):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
# Delete the classification model's data directory
model_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
if os.path.exists(model_dir):
shutil.rmtree(model_dir)
return JSONResponse(
content=(
{
"success": True,
"message": f"Successfully deleted classification model {name}.",
}
),
status_code=200,
)

View File

@ -91,7 +91,7 @@ class OpenAIClient(GenAIClient):
# Default to 128K for ChatGPT models, 8K for others
model_name = self.genai_config.model.lower()
if "gpt-4o" in model_name:
if "gpt" in model_name:
self.context_size = 128000
else:
self.context_size = 8192

View File

@ -5,12 +5,15 @@
"renameCategory": "Rename Class",
"deleteCategory": "Delete Class",
"deleteImages": "Delete Images",
"trainModel": "Train Model"
"trainModel": "Train Model",
"addClassification": "Add Classification",
"deleteModels": "Delete Models"
},
"toast": {
"success": {
"deletedCategory": "Deleted Class",
"deletedImage": "Deleted Images",
"deletedModel": "Successfully deleted {{count}} model(s)",
"categorizedImage": "Successfully Classified Image",
"trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training."
@ -18,6 +21,7 @@
"error": {
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
"trainingFailed": "Failed to start model training: {{errorMessage}}"
}
@ -26,6 +30,11 @@
"title": "Delete Class",
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
},
"deleteModel": {
"title": "Delete Classification Model",
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
},
"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."
@ -52,6 +61,10 @@
},
"categorizeImageAs": "Classify Image As:",
"categorizeImage": "Classify Image",
"menu": {
"objects": "Objects",
"states": "States"
},
"noModels": {
"object": {
"title": "No Object Classification Models",

View File

@ -33,6 +33,7 @@
"type": {
"details": "details",
"snapshot": "snapshot",
"thumbnail": "thumbnail",
"video": "video",
"object_lifecycle": "object lifecycle"
},
@ -41,7 +42,7 @@
"noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings",
"scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
"scrollViewTips": "Click to view the significant moments of this object's lifecycle.",
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"count": "{{first}} of {{second}}",
"trackedPoint": "Tracked Point",

View File

@ -13,6 +13,9 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01;
type ObjectTrackOverlayProps = {
camera: string;
showBoundingBoxes?: boolean;
@ -166,41 +169,45 @@ export default function ObjectTrackOverlay({
}) || [];
// show full path once current time has reached the object's start time
const combinedPoints = [...savedPathPoints, ...eventSequencePoints]
.sort((a, b) => a.timestamp - b.timestamp)
.filter(
(point) =>
currentTime >= (eventData?.start_time ?? 0) &&
point.timestamp >= (eventData?.start_time ?? 0) &&
point.timestamp <= (eventData?.end_time ?? Infinity),
);
// event.start_time is in DETECT stream time, so convert it to record stream time for comparison
const eventStartTimeRecord =
(eventData?.start_time ?? 0) + annotationOffset / 1000;
const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
const combinedPoints = allPoints.filter(
(point) =>
currentTime >= eventStartTimeRecord - TOLERANCE &&
point.timestamp <= effectiveCurrentTime + TOLERANCE,
);
// Get color for this object
const label = eventData?.label || "unknown";
const color = getObjectColor(label, objectId);
// Get current zones
// zones (with tolerance for browsers with seek precision by-design issues)
const currentZones =
timelineData
?.filter(
(event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime,
event.timestamp <= effectiveCurrentTime + TOLERANCE,
)
.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp,
)[0]?.data?.zones || [];
// Get current bounding box
const currentBox = timelineData
?.filter(
(event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime && event.data.box,
)
.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp,
)[0]?.data?.box;
// bounding box (with tolerance for browsers with seek precision by-design issues)
const boxCandidates = timelineData?.filter(
(event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime + TOLERANCE &&
event.data.box,
);
const currentBox = boxCandidates?.sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp,
)[0]?.data?.box;
return {
objectId,
@ -221,6 +228,7 @@ export default function ObjectTrackOverlay({
getObjectColor,
config,
camera,
annotationOffset,
]);
// Collect all zones across all objects
@ -274,9 +282,10 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback(
(timestamp: number) => {
onSeekToTime?.(timestamp, false);
// Convert detect stream timestamp to record stream timestamp before seeking
onSeekToTime?.(timestamp + annotationOffset / 1000, false);
},
[onSeekToTime],
[onSeekToTime, annotationOffset],
);
const zonePolygons = useMemo(() => {

View File

@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<div className="w-full flex-1 landscape:flex">
<Slider
value={[annotationOffset]}
min={-1500}
max={1500}
min={-2500}
max={2500}
step={50}
onValueChange={handleChange}
/>

View File

@ -1,577 +0,0 @@
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../../ui/sheet";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api";
import {
ReviewDetailPaneType,
ReviewSegment,
ThreatLevel,
} from "@/types/review";
import { Event } from "@/types/event";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import TrackingDetails from "./TrackingDetails";
import Chip from "@/components/indicators/Chip";
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FaArrowsRotate } from "react-icons/fa6";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { baseUrl } from "@/api/baseUrl";
import { shareOrCopy } from "@/utils/browserUtil";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
setReview: (review: ReviewSegment | undefined) => void;
};
export default function ReviewDetailDialog({
review,
setReview,
}: ReviewDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const navigate = useNavigate();
// upload
const [upload, setUpload] = useState<Event>();
// data
const { data: events } = useSWR<Event[]>(
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
);
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
const aiThreatLevel = useMemo(() => {
if (
!aiAnalysis ||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
) {
return "None";
}
let concerns = "";
switch (aiAnalysis.potential_threat_level) {
case ThreatLevel.SUSPICIOUS:
concerns = `${t("suspiciousActivity", { ns: "views/events" })}\n`;
break;
case ThreatLevel.DANGER:
concerns = `${t("threateningActivity", { ns: "views/events" })}\n`;
break;
}
(aiAnalysis.other_concerns ?? []).forEach((c) => {
concerns += `${c}\n`;
});
return concerns || "None";
}, [aiAnalysis, t]);
const hasMismatch = useMemo(() => {
if (!review || !events) {
return false;
}
return events.length != review?.data.detections.length;
}, [review, events]);
const missingObjects = useMemo(() => {
if (!review || !events) {
return [];
}
const detectedIds = review.data.detections;
const missing = Array.from(
new Set(
events
.filter((event) => !detectedIds.includes(event.id))
.map((event) => event.label),
),
);
return missing;
}, [review, events]);
const formattedDate = useFormattedTimestamp(
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
ns: "common",
}),
config?.ui.timezone,
);
// content
const [selectedEvent, setSelectedEvent] = useState<Event>();
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
// dialog and mobile page
const [isOpen, setIsOpen] = useState(review != undefined);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (!open) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout(() => {
setReview(undefined);
setSelectedEvent(undefined);
setPane("overview");
}, 300);
}
},
[setReview, setIsOpen],
);
useEffect(() => {
setIsOpen(review != undefined);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [review]);
// keyboard listener
useKeyboardListener(["Esc"], (key, modifiers) => {
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
setIsOpen(false);
}
return true;
});
const Overlay = isDesktop ? Sheet : MobilePage;
const Content = isDesktop ? SheetContent : MobilePageContent;
const Header = isDesktop ? SheetHeader : MobilePageHeader;
const Title = isDesktop ? SheetTitle : MobilePageTitle;
const Description = isDesktop ? SheetDescription : MobilePageDescription;
if (!review) {
return;
}
return (
<>
<Overlay
open={isOpen ?? false}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
>
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
onEventUploaded={() => {
if (upload) {
upload.plus_id = "new_upload";
}
}}
/>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && pane == "overview"
? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl",
isMobile && "px-4",
)}
>
<span tabIndex={0} className="sr-only" />
{pane == "overview" && (
<Header className="justify-center">
<Title>{t("details.item.title")}</Title>
<Description className="sr-only">
{t("details.item.desc")}
</Description>
<div
className={cn(
"absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("details.item.button.share")}
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.share")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</Header>
)}
{pane == "overview" && (
<div className="flex flex-col gap-5 md:mt-3">
{aiAnalysis != undefined && (
<div
className={cn(
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
isDesktop && "m-2 w-[90%]",
)}
>
{t("aiAnalysis.title")}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="text-sm">{aiAnalysis.scene}</div>
<div className="text-sm text-primary/40">
{t("details.score.label")}
</div>
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
<div className="text-sm text-primary/40">
{t("concerns.label")}
</div>
<div className="text-sm">{aiThreatLevel}</div>
</div>
)}
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.camera")}
</div>
<div className="text-sm smart-capitalize">
<CameraNameLabel camera={review.camera} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">
{t("details.objects")}
</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
{events?.map((event) => {
return (
<div
key={event.id}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{getIconForLabel(
event.label,
"size-3 text-primary",
)}
{event.sub_label ??
event.label.replaceAll("_", " ")}{" "}
({Math.round(event.data.top_score * 100)}%)
<Tooltip>
<TooltipTrigger>
<div
className="cursor-pointer"
onClick={() => {
navigate(`/explore?event_id=${event.id}`);
}}
>
<LuSearch className="size-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.viewInExplore")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
);
})}
</div>
</div>
{review.data.zones.length > 0 && (
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.zones")}
</div>
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
{review.data.zones.map((zone) => {
return (
<div
key={zone}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{zone.replaceAll("_", " ")}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{hasMismatch && (
<div className="p-4 text-center text-sm">
{(() => {
const detectedCount = Math.abs(
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
return t("details.item.tips.mismatch", {
count: detectedCount,
});
})()}
{missingObjects.length > 0 && (
<div className="mt-2">
<Trans
ns="views/explore"
values={{
objects: missingObjects
.map((x) => getTranslatedLabel(x))
.join(", "),
}}
>
details.item.tips.hasMissingObjects
</Trans>
</div>
)}
</div>
)}
<div className="relative flex size-full flex-col gap-2">
{events?.map((event) => (
<EventItem
key={event.id}
event={event}
setPane={setPane}
setSelectedEvent={setSelectedEvent}
setUpload={setUpload}
/>
))}
</div>
</div>
)}
{pane == "details" && selectedEvent && (
<div className="mt-0 flex size-full flex-col gap-2">
<TrackingDetails event={selectedEvent} setPane={setPane} />
</div>
)}
</Content>
</Overlay>
</>
);
}
type EventItemProps = {
event: Event;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
};
function EventItem({
event,
setPane,
setSelectedEvent,
setUpload,
}: EventItemProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
const imgRef = useRef(null);
const [hovered, setHovered] = useState(isMobile);
const navigate = useNavigate();
return (
<>
<div
className={cn(
"relative mr-auto",
!event.has_snapshot && "flex flex-row items-center justify-center",
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
key={event.id}
>
{event.has_snapshot && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
<img
ref={imgRef}
className={cn(
"select-none rounded-lg object-contain transition-opacity",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
/>
{hovered && (
<div>
<div
className={cn("absolute right-1 top-1 flex items-center gap-2")}
>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</Tooltip>
{event.has_snapshot &&
event.plus_id == undefined &&
event.data.type == "object" &&
config?.plus.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setUpload?.(event);
}}
>
<FrigatePlusIcon className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_clip && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setPane("details");
setSelectedEvent(event);
}}
>
<FaArrowsRotate className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.viewTrackingDetails.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_snapshot && config?.semantic_search.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
);
}}
>
<FaImages className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@ -31,10 +31,9 @@ import {
FaDownload,
FaHistory,
FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa";
import TrackingDetails from "./TrackingDetails";
import { TrackingDetails } from "./TrackingDetails";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import {
MobilePage,
MobilePageContent,
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading";
const SEARCH_TABS = [
"details",
"snapshot",
"video",
"tracking_details",
] as const;
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = {
@ -109,6 +104,7 @@ export default function SearchDetailDialog({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
// tabs
@ -149,16 +145,6 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS];
if (!search.has_snapshot) {
const index = views.indexOf("snapshot");
views.splice(index, 1);
}
if (!search.has_clip) {
const index = views.indexOf("video");
views.splice(index, 1);
}
if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details");
views.splice(index, 1);
@ -173,10 +159,50 @@ export default function SearchDetailDialog({
}
if (!searchTabs.includes(pageToggle)) {
setSearchPage("details");
setSearchPage("snapshot");
}
}, [pageToggle, searchTabs, setSearchPage]);
// Tabs component for reuse
const tabsComponent = (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">
{item === "snapshot"
? search?.has_snapshot
? t("type.snapshot")
: t("type.thumbnail")
: t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
);
if (!search) {
return;
}
@ -190,92 +216,188 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
<Overlay
open={isOpen}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
<DetailStreamProvider
isDetailMode={true}
currentTime={(search as unknown as Event)?.start_time ?? 0}
camera={(search as unknown as Event)?.camera ?? ""}
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isMobile && "px-4",
)}
<Overlay
open={isOpen}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
>
<Header>
<Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only">
{t("trackedObjectDetails")}
</Description>
</Header>
<ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isDesktop &&
page == "tracking_details" &&
"lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4",
)}
>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
<Header>
<Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only">
{t("trackedObjectDetails")}
</Description>
</Header>
{isDesktop ? (
page === "tracking_details" ? (
<TrackingDetails
className="size-full"
event={search as unknown as Event}
tabs={tabsComponent}
/>
) : (
<div className="flex h-full gap-4 overflow-hidden">
<div
className={cn(
"scrollbar-container flex-[3] overflow-y-hidden",
page === "snapshot" && !search.has_snapshot && "flex-[2]",
)}
>
{item == "details" && <FaRegListAlt className="size-4" />}
{item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">{t(`type.${item}`)}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
/>
)}
{page == "snapshot" && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page == "video" && <VideoTab search={search} />}
{page == "tracking_details" && (
<TrackingDetails
className="w-full overflow-x-hidden"
event={search as unknown as Event}
fullscreen={true}
setPane={() => {}}
/>
)}
</Content>
</Overlay>
{page === "snapshot" && search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page === "snapshot" && !search.has_snapshot && (
<img
className="size-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
</div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
{tabsComponent}
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "snapshot" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
</div>
</div>
</div>
)
) : (
<>
<ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && (
<PiPath className="size-4" />
)}
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{page == "snapshot" && (
<>
{search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page == "snapshot" && !search.has_snapshot && (
<img
className="w-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
<Heading as="h3" className="mt-2 smart-capitalize">
{t("type.details")}
</Heading>
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
</>
)}
{page == "tracking_details" && (
<TrackingDetails event={search as unknown as Event} />
)}
</>
)}
</Content>
</Overlay>
</DetailStreamProvider>
);
}
@ -285,6 +407,7 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void;
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
showThumbnail?: boolean;
};
function ObjectDetailsTab({
search,
@ -292,6 +415,7 @@ function ObjectDetailsTab({
setSearch,
setSimilarity,
setInputFocused,
showThumbnail = true,
}: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@ -873,66 +997,71 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col gap-2 pl-6">
<img
className="aspect-video select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
<div
className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")}
>
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
search.data.type == "object" && (
<Button
{showThumbnail && (
<div className="flex w-full flex-col gap-2 pl-6">
<img
className="aspect-video select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
<div
className={cn(
"flex w-full flex-row gap-2",
isMobile && "flex-col",
)}
>
{config?.semantic_search.enabled &&
setSimilarity != undefined &&
search.data.type == "object" && (
<Button
className="w-full"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
setSimilarity();
}}
>
<div className="flex gap-1">
<LuSearch />
{t("itemMenu.findSimilar.label")}
</div>
</Button>
)}
{hasFace && (
<FaceSelectionDialog
className="w-full"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
setSimilarity();
}}
faceNames={faceNames}
onTrainAttempt={onTrainFace}
>
<div className="flex gap-1">
<LuSearch />
{t("itemMenu.findSimilar.label")}
</div>
</Button>
)}
{hasFace && (
<FaceSelectionDialog
className="w-full"
faceNames={faceNames}
onTrainAttempt={onTrainFace}
>
<Button className="w-full">
<div className="flex gap-1">
<TbFaceId />
{t("trainFace", { ns: "views/faceLibrary" })}
</div>
</Button>
</FaceSelectionDialog>
)}
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button className="w-full" onClick={onTranscribe}>
<div className="flex gap-1">
<CgTranscript />
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
<Button className="w-full">
<div className="flex gap-1">
<TbFaceId />
{t("trainFace", { ns: "views/faceLibrary" })}
</div>
</Button>
</FaceSelectionDialog>
)}
{config?.cameras[search?.camera].audio_transcription.enabled &&
search?.label == "speech" &&
search?.end_time && (
<Button className="w-full" onClick={onTranscribe}>
<div className="flex gap-1">
<CgTranscript />
{t("itemMenu.audioTranscription.label")}
</div>
</Button>
)}
</div>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled &&
@ -1167,7 +1296,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}>
<div className={cn("flex max-w-sm flex-col space-y-3")}>
<div className={"text-lg leading-none"}>
{t("explore.plus.submitToPlus.label")}
</div>
@ -1176,7 +1305,7 @@ export function ObjectSnapshotTab({
</div>
</div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
{state == "reviewing" && (
<>
<div>

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,7 @@ export default function DetailStream({
elementRef: scrollRef,
});
const effectiveTime = currentTime + annotationOffset / 1000;
const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
@ -213,6 +213,7 @@ export default function DetailStream({
config={config}
onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)}
@ -278,6 +279,7 @@ type ReviewGroupProps = {
onActivate?: () => void;
onOpenUpload?: (e: Event) => void;
effectiveTime?: number;
annotationOffset: number;
alwaysExpandActive?: boolean;
};
@ -290,11 +292,14 @@ function ReviewGroup({
onActivate,
onOpenUpload,
effectiveTime,
annotationOffset,
alwaysExpandActive = false,
}: ReviewGroupProps) {
const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false);
const start = review.start_time ?? 0;
// review.start_time is in detect time, convert to record for seeking
const startRecord = start + annotationOffset / 1000;
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => {
@ -371,7 +376,7 @@ function ReviewGroup({
)}
onClick={() => {
onActivate?.();
onSeek(start);
onSeek(startRecord);
}}
>
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
@ -450,6 +455,7 @@ function ReviewGroup({
key={event.id}
event={event}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
onSeek={onSeek}
onOpenUpload={onOpenUpload}
/>
@ -483,12 +489,14 @@ function ReviewGroup({
type EventListProps = {
event: Event;
effectiveTime?: number;
annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void;
};
function EventList({
event,
effectiveTime,
annotationOffset,
onSeek,
onOpenUpload,
}: EventListProps) {
@ -505,14 +513,17 @@ function EventList({
if (event) {
setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
onSeek(event.start_time);
// event.start_time is detect time, convert to record
const recordTime = event.start_time + annotationOffset / 1000;
onSeek(recordTime);
} else {
setSelectedObjectIds([]);
}
};
const handleTimelineClick = (ts: number, play?: boolean) => {
handleObjectSelect(event);
setSelectedObjectIds([]);
setSelectedObjectIds([event.id]);
onSeek(ts, play);
};
@ -554,7 +565,6 @@ function EventList({
)}
onClick={(e) => {
e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event);
}}
role="button"
@ -568,7 +578,6 @@ function EventList({
className="flex flex-1 items-center gap-2"
onClick={(e) => {
e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event);
}}
role="button"
@ -607,6 +616,7 @@ function EventList({
eventId={event.id}
onSeek={handleTimelineClick}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
startTime={event.start_time}
endTime={event.end_time}
/>
@ -621,6 +631,7 @@ type LifecycleItemProps = {
isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void;
effectiveTime?: number;
annotationOffset: number;
isTimelineActive?: boolean;
};
@ -629,6 +640,7 @@ function LifecycleItem({
isActive,
onSeek,
effectiveTime,
annotationOffset,
isTimelineActive = false,
}: LifecycleItemProps) {
const { t } = useTranslation("views/events");
@ -682,7 +694,8 @@ function LifecycleItem({
<div
role="button"
onClick={() => {
onSeek?.(item.timestamp, false);
const recordTimestamp = item.timestamp + annotationOffset / 1000;
onSeek?.(recordTimestamp, false);
}}
className={cn(
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
@ -751,12 +764,14 @@ function ObjectTimeline({
eventId,
onSeek,
effectiveTime,
annotationOffset,
startTime,
endTime,
}: {
eventId: string;
onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number;
annotationOffset: number;
startTime?: number;
endTime?: number;
}) {
@ -857,6 +872,7 @@ function ObjectTimeline({
onSeek={onSeek}
isActive={isActive}
effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isTimelineActive={isWithinEventRange}
/>
);

View File

@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
isDetailMode: boolean;
currentTime: number;
camera: string;
initialSelectedObjectIds?: string[];
}
export function DetailStreamProvider({
@ -29,8 +30,11 @@ export function DetailStreamProvider({
isDetailMode,
currentTime,
camera,
initialSelectedObjectIds,
}: DetailStreamProviderProps) {
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]);
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
() => initialSelectedObjectIds ?? [],
);
const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) {

View File

@ -2,7 +2,7 @@ import { baseUrl } from "@/api/baseUrl";
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils";
@ -10,13 +10,35 @@ import {
CustomClassificationModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa";
import { MdModelTraining } from "react-icons/md";
import { LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
import { useOverlayState } from "@/hooks/use-overlay-state";
import axios from "axios";
import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import BlurredIconButton from "@/components/button/BlurredIconButton";
const allModelTypes = ["objects", "states"] as const;
type ModelType = (typeof allModelTypes)[number];
@ -126,7 +148,7 @@ export default function ModelSelectionView({
onClick={() => setNewModel(true)}
>
<FaFolderPlus />
Add Classification
{t("button.addClassification")}
</Button>
</div>
</div>
@ -142,6 +164,7 @@ export default function ModelSelectionView({
key={config.name}
config={config}
onClick={() => onClick(config)}
onDelete={() => refreshConfig()}
/>
))}
</div>
@ -179,12 +202,53 @@ function NoModelsView({
type ModelCardProps = {
config: CustomClassificationModelConfig;
onClick: () => void;
onDelete: () => void;
};
function ModelCard({ config, onClick }: ModelCardProps) {
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
const { t } = useTranslation(["views/classificationModel"]);
const { data: dataset } = useSWR<{
[id: string]: string[];
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
return false;
});
const handleDelete = useCallback(async () => {
await axios
.delete(`classification/${config.name}`)
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.deletedModel", { count: 1 }), {
position: "top-center",
});
onDelete();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.deleteModelFailed", { errorMessage }), {
position: "top-center",
});
});
}, [config, onDelete, t]);
const handleDeleteClick = useCallback(() => {
if (bypassDialogRef.current) {
handleDelete();
} else {
setDeleteDialogOpen(true);
}
}, [handleDelete]);
const coverImage = useMemo(() => {
if (!dataset) {
return undefined;
@ -204,22 +268,66 @@ function ModelCard({ config, onClick }: ModelCardProps) {
}, [dataset]);
return (
<div
key={config.name}
className={cn(
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
"outline-transparent duration-500",
)}
onClick={() => onClick()}
>
<img
className="size-full"
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
/>
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 text-lg smart-capitalize">
{config.name}
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteModel.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("deleteModel.single", { name: config.name })}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div
className={cn(
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
)}
onClick={onClick}
>
<img
className="size-full"
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
/>
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
{config.name}
</div>
<div className="absolute bottom-2 right-2 z-40">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<BlurredIconButton>
<FiMoreVertical className="size-5 text-white" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDeleteClick}>
<LuTrash2 className="mr-2 size-4" />
<span>
{bypassDialogRef.current
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</>
);
}

View File

@ -893,7 +893,7 @@ function ObjectTrainGrid({
// selection
const [selectedEvent, setSelectedEvent] = useState<Event>();
const [dialogTab, setDialogTab] = useState<SearchTab>("details");
const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot");
// handlers

View File

@ -214,7 +214,7 @@ export default function SearchView({
// detail
const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details");
const [page, setPage] = useState<SearchTab>("snapshot");
// search interaction
@ -222,7 +222,7 @@ export default function SearchView({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback(
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
(item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => {
if (selectedObjects.length > 1 || ctrl) {
const index = selectedObjects.indexOf(item.id);