Miscellaneous fixes (#20833)

* remove frigate+ icon from explore grid footer

* add margin

* pointer cursor on event menu items in detail stream

* don't show submit to plus for non-objects and if plus is disabled

* tweak spacing in annotation settings popover

* Fix deletion of classification images and library

* Ensure after creating a class that things are correct

* Fix dialog getting stuck

* Only show the genai summary popup on mobile when timeline is open

* fix audio transcription embedding

* spacing

* hide x icon on restart sheet to prevent closure issues

* prevent x overflow in detail stream on mobile safari

* ensure name is valid for search effect trigger

* add trigger to detail actions menu

* move find similar to actions menu

* Use a column layout for MobilePageContent in PlatformAwareSheet

 This is so the header is outside the scrolling area and the content can grow/scroll independently. This now matches the way it's done in classification

* Skip azure execution provider

* add optional ref to always scroll to top

the more filters in explore was not scrolled to the top on open due to the use of framer motion

* fix title classes on desktop

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins 2025-11-07 07:53:27 -06:00 committed by GitHub
parent a15399fed5
commit 530b69b877
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 249 additions and 237 deletions

View File

@ -662,8 +662,11 @@ def delete_classification_dataset_images(
if os.path.isfile(file_path): if os.path.isfile(file_path):
os.unlink(file_path) os.unlink(file_path)
if os.path.exists(folder) and not os.listdir(folder):
os.rmdir(folder)
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}), content=({"success": True, "message": "Successfully deleted images."}),
status_code=200, status_code=200,
) )
@ -723,7 +726,7 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
os.unlink(training_file) os.unlink(training_file)
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}), content=({"success": True, "message": "Successfully categorized image."}),
status_code=200, status_code=200,
) )
@ -761,7 +764,7 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
os.unlink(file_path) os.unlink(file_path)
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}), content=({"success": True, "message": "Successfully deleted images."}),
status_code=200, status_code=200,
) )

View File

@ -9,7 +9,6 @@ from typing import Optional
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import ( from frigate.const import (
@ -32,11 +31,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
self, self,
config: FrigateConfig, config: FrigateConfig,
requestor: InterProcessRequestor, requestor: InterProcessRequestor,
embeddings,
metrics: DataProcessorMetrics, metrics: DataProcessorMetrics,
): ):
super().__init__(config, metrics, None) super().__init__(config, metrics, None)
self.config = config self.config = config
self.requestor = requestor self.requestor = requestor
self.embeddings = embeddings
self.recognizer = None self.recognizer = None
self.transcription_lock = threading.Lock() self.transcription_lock = threading.Lock()
self.transcription_thread = None self.transcription_thread = None
@ -128,10 +129,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
) )
# Embed the description # Embed the description
self.requestor.send_data( self.embeddings.embed_description(event_id, transcription)
EmbeddingsRequestEnum.embed_description.value,
{"id": event_id, "description": transcription},
)
except DoesNotExist: except DoesNotExist:
logger.debug("No recording found for audio transcription post-processing") logger.debug("No recording found for audio transcription post-processing")

View File

@ -226,7 +226,9 @@ class EmbeddingMaintainer(threading.Thread):
for c in self.config.cameras.values() for c in self.config.cameras.values()
): ):
self.post_processors.append( self.post_processors.append(
AudioTranscriptionPostProcessor(self.config, self.requestor, metrics) AudioTranscriptionPostProcessor(
self.config, self.requestor, self.embeddings, metrics
)
) )
semantic_trigger_processor: SemanticTriggerProcessor | None = None semantic_trigger_processor: SemanticTriggerProcessor | None = None

View File

@ -369,6 +369,10 @@ def get_ort_providers(
"enable_cpu_mem_arena": False, "enable_cpu_mem_arena": False,
} }
) )
elif provider == "AzureExecutionProvider":
# Skip Azure provider - not typically available on local hardware
# and prevents fallback to OpenVINO when it's the first provider
continue
else: else:
providers.append(provider) providers.append(provider)
options.append({}) options.append({})

View File

@ -14,7 +14,6 @@ type SearchThumbnailProps = {
findSimilar: () => void; findSimilar: () => void;
refreshResults: () => void; refreshResults: () => void;
showTrackingDetails: () => void; showTrackingDetails: () => void;
showSnapshot: () => void;
addTrigger: () => void; addTrigger: () => void;
}; };
@ -24,7 +23,6 @@ export default function SearchThumbnailFooter({
findSimilar, findSimilar,
refreshResults, refreshResults,
showTrackingDetails, showTrackingDetails,
showSnapshot,
addTrigger, addTrigger,
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]); const { t } = useTranslation(["views/search"]);
@ -62,7 +60,6 @@ export default function SearchThumbnailFooter({
findSimilar={findSimilar} findSimilar={findSimilar}
refreshResults={refreshResults} refreshResults={refreshResults}
showTrackingDetails={showTrackingDetails} showTrackingDetails={showTrackingDetails}
showSnapshot={showSnapshot}
addTrigger={addTrigger} addTrigger={addTrigger}
/> />
</div> </div>

View File

@ -6,10 +6,7 @@ import { toast } from "sonner";
import axios from "axios"; import axios from "axios";
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu"; import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi"; import { FiMoreVertical } from "react-icons/fi";
import { FaArrowsRotate } from "react-icons/fa6";
import { MdImageSearch } from "react-icons/md"; import { MdImageSearch } from "react-icons/md";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { import {
ContextMenu, ContextMenu,
@ -33,23 +30,18 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import useSWR from "swr"; import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { BsFillLightningFill } from "react-icons/bs"; import { BsFillLightningFill } from "react-icons/bs";
import BlurredIconButton from "../button/BlurredIconButton"; import BlurredIconButton from "../button/BlurredIconButton";
import { PiPath } from "react-icons/pi";
type SearchResultActionsProps = { type SearchResultActionsProps = {
searchResult: SearchResult; searchResult: SearchResult;
findSimilar: () => void; findSimilar: () => void;
refreshResults: () => void; refreshResults: () => void;
showTrackingDetails: () => void; showTrackingDetails: () => void;
showSnapshot: () => void;
addTrigger: () => void; addTrigger: () => void;
isContextMenu?: boolean; isContextMenu?: boolean;
children?: ReactNode; children?: ReactNode;
@ -60,7 +52,6 @@ export default function SearchResultActions({
findSimilar, findSimilar,
refreshResults, refreshResults,
showTrackingDetails, showTrackingDetails,
showSnapshot,
addTrigger, addTrigger,
isContextMenu = false, isContextMenu = false,
children, children,
@ -129,7 +120,7 @@ export default function SearchResultActions({
aria-label={t("itemMenu.viewTrackingDetails.aria")} aria-label={t("itemMenu.viewTrackingDetails.aria")}
onClick={showTrackingDetails} onClick={showTrackingDetails}
> >
<FaArrowsRotate className="mr-2 size-4" /> <PiPath className="mr-2 size-4" />
<span>{t("itemMenu.viewTrackingDetails.label")}</span> <span>{t("itemMenu.viewTrackingDetails.label")}</span>
</MenuItem> </MenuItem>
)} )}
@ -152,18 +143,14 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span> <span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem> </MenuItem>
)} )}
{isMobileOnly && {config?.semantic_search?.enabled &&
config?.plus?.enabled && searchResult.data.type == "object" && (
searchResult.has_snapshot &&
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
<MenuItem <MenuItem
aria-label={t("itemMenu.submitToPlus.aria")} aria-label={t("itemMenu.findSimilar.aria")}
onClick={showSnapshot} onClick={findSimilar}
> >
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" /> <MdImageSearch className="mr-2 size-4" />
<span>{t("itemMenu.submitToPlus.label")}</span> <span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem> </MenuItem>
)} )}
<MenuItem <MenuItem
@ -211,44 +198,6 @@ export default function SearchResultActions({
</ContextMenu> </ContextMenu>
) : ( ) : (
<> <>
{config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={findSimilar}
aria-label={t("itemMenu.findSimilar.aria")}
>
<MdImageSearch className="size-5" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
{!isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={showSnapshot}
aria-label={t("itemMenu.submitToPlus.aria")}
>
<FrigatePlusIcon className="size-5" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<BlurredIconButton aria-label={t("itemMenu.more.aria")}> <BlurredIconButton aria-label={t("itemMenu.more.aria")}>

View File

@ -4,6 +4,7 @@ import {
useEffect, useEffect,
useState, useState,
useCallback, useCallback,
useRef,
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@ -121,17 +122,20 @@ export function MobilePagePortal({
type MobilePageContentProps = { type MobilePageContentProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
scrollerRef?: React.RefObject<HTMLDivElement>;
}; };
export function MobilePageContent({ export function MobilePageContent({
children, children,
className, className,
scrollerRef,
}: MobilePageContentProps) { }: MobilePageContentProps) {
const context = useContext(MobilePageContext); const context = useContext(MobilePageContext);
if (!context) if (!context)
throw new Error("MobilePageContent must be used within MobilePage"); throw new Error("MobilePageContent must be used within MobilePage");
const [isVisible, setIsVisible] = useState(context.open); const [isVisible, setIsVisible] = useState(context.open);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (context.open) { if (context.open) {
@ -140,15 +144,27 @@ export function MobilePageContent({
}, [context.open]); }, [context.open]);
const handleAnimationComplete = () => { const handleAnimationComplete = () => {
if (!context.open) { if (context.open) {
// After opening animation completes, ensure scroller is at the top
if (scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
} else {
setIsVisible(false); setIsVisible(false);
} }
}; };
useEffect(() => {
if (context.open && scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
}, [context.open, scrollerRef]);
return ( return (
<AnimatePresence> <AnimatePresence>
{isVisible && ( {isVisible && (
<motion.div <motion.div
ref={containerRef}
className={cn( className={cn(
"fixed inset-0 z-50 mb-12 bg-background", "fixed inset-0 z-50 mb-12 bg-background",
isPWA && "mb-16", isPWA && "mb-16",

View File

@ -97,14 +97,12 @@ export default function ClassificationSelectionDialog({
return ( return (
<div className={className ?? "flex"}> <div className={className ?? "flex"}>
{newClass && ( <TextEntryDialog
<TextEntryDialog open={newClass}
open={true} setOpen={setNewClass}
setOpen={setNewClass} title={t("createCategory.new")}
title={t("createCategory.new")} onSave={(newCat) => onCategorizeImage(newCat)}
onSave={(newCat) => onCategorizeImage(newCat)} />
/>
)}
<Tooltip> <Tooltip>
<Selector> <Selector>

View File

@ -141,50 +141,52 @@ export function AnnotationSettingsPane({
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6" className="flex flex-1 flex-col space-y-3"
> >
<FormField <FormField
control={form.control} control={form.control}
name="annotationOffset" name="annotationOffset"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between space-x-2"> <>
<div className="flex flex-col gap-1"> <FormItem className="flex flex-row items-start justify-between space-x-2">
<FormLabel> <div className="flex flex-col gap-1">
{t("trackingDetails.annotationSettings.offset.label")} <FormLabel>
</FormLabel> {t("trackingDetails.annotationSettings.offset.label")}
<FormDescription> </FormLabel>
<Trans ns="views/explore"> <FormDescription>
trackingDetails.annotationSettings.offset.millisecondsToOffset <Trans ns="views/explore">
</Trans> trackingDetails.annotationSettings.offset.millisecondsToOffset
<FormMessage /> </Trans>
<div className="mt-2"> <FormMessage />
{t("trackingDetails.annotationSettings.offset.tips")} </FormDescription>
<div className="mt-2 flex items-center text-primary"> </div>
<Link <div className="flex flex-col gap-3">
to={getLocaleDocUrl("configuration/reference")} <div className="min-w-24">
target="_blank" <FormControl>
rel="noopener noreferrer" <Input
className="inline" className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
> placeholder="0"
{t("readTheDocumentation", { ns: "common" })} {...field}
<LuExternalLink className="ml-2 inline-flex size-3" /> />
</Link> </FormControl>
</div>
</div> </div>
</FormDescription> </div>
</div> </FormItem>
<div className="flex flex-col gap-3"> <div className="mt-1 text-sm text-secondary-foreground">
<div className="min-w-24"> {t("trackingDetails.annotationSettings.offset.tips")}
<FormControl> <div className="mt-2 flex items-center text-primary-variant">
<Input <Link
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" to={getLocaleDocUrl("configuration/reference")}
placeholder="0" target="_blank"
{...field} rel="noopener noreferrer"
/> className="inline"
</FormControl> >
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div> </div>
</div> </div>
</FormItem> </>
)} )}
/> />

View File

@ -111,6 +111,23 @@ export default function DetailActionsMenu({
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{config?.semantic_search.enabled && search.data.type == "object" && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setTimeout(() => {
navigate(
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
);
}, 0);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.addTrigger.label")}</span>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenu> </DropdownMenu>

View File

@ -1242,106 +1242,110 @@ function ObjectDetailsTab({
</div> </div>
</div> </div>
<div {search.data.type === "object" &&
className={cn( !search.plus_id &&
"my-2 flex w-full flex-col justify-between gap-1.5", config?.plus?.enabled && (
state == "submitted" && "flex-row", <div
)} className={cn(
> "my-2 flex w-full flex-col justify-between gap-1.5",
<div className="text-sm text-primary/40"> state == "submitted" && "flex-row",
<div className="flex flex-row items-center gap-1"> )}
{t("explore.plus.submitToPlus.label", { >
ns: "components/dialog", <div className="text-sm text-primary/40">
})} <div className="flex flex-row items-center gap-1">
<Popover> {t("explore.plus.submitToPlus.label", {
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent
container={popoverContainerRef.current}
className="w-80 text-xs"
>
{t("explore.plus.submitToPlus.desc", {
ns: "components/dialog", ns: "components/dialog",
})} })}
</PopoverContent> <Popover>
</Popover> <PopoverTrigger asChild>
</div> <div className="cursor-pointer p-0">
</div> <LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent
container={popoverContainerRef.current}
className="w-80 text-xs"
>
{t("explore.plus.submitToPlus.desc", {
ns: "components/dialog",
})}
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-row items-center justify-between gap-2 text-sm"> <div className="flex flex-row items-center justify-between gap-2 text-sm">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
<div> <div>
{i18n.language === "en" ? ( {i18n.language === "en" ? (
// English with a/an logic plus label // English with a/an logic plus label
<> <>
{/^[aeiou]/i.test(search?.label || "") ? ( {/^[aeiou]/i.test(search?.label || "") ? (
<Trans <Trans
ns="components/dialog" ns="components/dialog"
values={{ label: search?.label }} values={{ label: search?.label }}
> >
explore.plus.review.question.ask_an explore.plus.review.question.ask_an
</Trans> </Trans>
) : (
<Trans
ns="components/dialog"
values={{ label: search?.label }}
>
explore.plus.review.question.ask_a
</Trans>
)}
</>
) : ( ) : (
// For other languages
<Trans <Trans
ns="components/dialog" ns="components/dialog"
values={{ label: search?.label }} values={{
untranslatedLabel: search?.label,
translatedLabel: getTranslatedLabel(search?.label),
}}
> >
explore.plus.review.question.ask_a explore.plus.review.question.ask_full
</Trans> </Trans>
)} )}
</> </div>
) : ( <div className="flex max-w-xl flex-row gap-2">
// For other languages <Button
<Trans className="flex-1 bg-success"
ns="components/dialog" aria-label={t("button.yes", { ns: "common" })}
values={{ onClick={() => {
untranslatedLabel: search?.label, setState("uploading");
translatedLabel: getTranslatedLabel(search?.label), onSubmitToPlus(false);
}} }}
> >
explore.plus.review.question.ask_full {t("button.yes", { ns: "common" })}
</Trans> </Button>
)} <Button
</div> className="flex-1 text-white"
<div className="flex max-w-xl flex-row gap-2"> aria-label={t("button.no", { ns: "common" })}
<Button variant="destructive"
className="flex-1 bg-success" onClick={() => {
aria-label={t("button.yes", { ns: "common" })} setState("uploading");
onClick={() => { onSubmitToPlus(true);
setState("uploading"); }}
onSubmitToPlus(false); >
}} {t("button.no", { ns: "common" })}
> </Button>
{t("button.yes", { ns: "common" })} </div>
</Button> </>
<Button )}
className="flex-1 text-white" {state == "uploading" && <ActivityIndicator />}
aria-label={t("button.no", { ns: "common" })} {state == "submitted" && (
variant="destructive" <div className="flex flex-row items-center justify-center gap-2">
onClick={() => { <FaCheckCircle className="size-4 text-success" />
setState("uploading"); {t("explore.plus.review.state.submitted")}
onSubmitToPlus(true); </div>
}} )}
>
{t("button.no", { ns: "common" })}
</Button>
</div>
</>
)}
{state == "uploading" && <ActivityIndicator />}
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")}
</div> </div>
)} </div>
</div> )}
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled && {config?.cameras[search.camera].objects.genai.enabled &&
!search.end_time && !search.end_time &&

View File

@ -457,7 +457,7 @@ export function TrackingDetails({
> >
{config?.cameras[event.camera]?.onvif.autotracking {config?.cameras[event.camera]?.onvif.autotracking
.enabled_in_config && ( .enabled_in_config && (
<div className="mb-2 text-sm text-danger"> <div className="mb-2 ml-3 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")} {t("trackingDetails.autoTrackingTips")}
</div> </div>
)} )}

View File

@ -20,7 +20,9 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useRef } from "react";
type PlatformAwareDialogProps = { type PlatformAwareDialogProps = {
trigger: JSX.Element; trigger: JSX.Element;
@ -79,6 +81,8 @@ export function PlatformAwareSheet({
open, open,
onOpenChange, onOpenChange,
}: PlatformAwareSheetProps) { }: PlatformAwareSheetProps) {
const scrollerRef = useRef<HTMLDivElement>(null);
if (isMobile) { if (isMobile) {
return ( return (
<MobilePage open={open} onOpenChange={onOpenChange}> <MobilePage open={open} onOpenChange={onOpenChange}>
@ -86,14 +90,22 @@ export function PlatformAwareSheet({
{trigger} {trigger}
</MobilePageTrigger> </MobilePageTrigger>
<MobilePagePortal> <MobilePagePortal>
<MobilePageContent className="h-full overflow-hidden"> <MobilePageContent
className="flex h-full flex-col"
scrollerRef={scrollerRef}
>
<MobilePageHeader <MobilePageHeader
className="mx-2" className="mx-2"
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
> >
<MobilePageTitle>{title}</MobilePageTitle> <MobilePageTitle>{title}</MobilePageTitle>
</MobilePageHeader> </MobilePageHeader>
<div className={contentClassName}>{content}</div> <div
ref={scrollerRef}
className={cn("flex-1 overflow-y-auto", contentClassName)}
>
{content}
</div>
</MobilePageContent> </MobilePageContent>
</MobilePagePortal> </MobilePagePortal>
</MobilePage> </MobilePage>

View File

@ -98,7 +98,11 @@ export default function RestartDialog({
open={restartingSheetOpen} open={restartingSheetOpen}
onOpenChange={() => setRestartingSheetOpen(false)} onOpenChange={() => setRestartingSheetOpen(false)}
> >
<SheetContent side="top" onInteractOutside={(e) => e.preventDefault()}> <SheetContent
side="top"
onInteractOutside={(e) => e.preventDefault()}
className="[&>button:first-of-type]:hidden"
>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<ActivityIndicator /> <ActivityIndicator />
<SheetHeader className="mt-5 text-center"> <SheetHeader className="mt-5 text-center">

View File

@ -230,6 +230,7 @@ export default function SearchFilterDialog({
<PlatformAwareSheet <PlatformAwareSheet
trigger={trigger} trigger={trigger}
title={t("more")} title={t("more")}
titleClassName="mb-5 -mt-3"
content={content} content={content}
contentClassName={cn( contentClassName={cn(
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4", "w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",

View File

@ -192,7 +192,7 @@ export default function DetailStream({
<div className="relative flex h-full flex-col"> <div className="relative flex h-full flex-col">
<div <div
ref={scrollRef} ref={scrollRef}
className="scrollbar-container flex-1 overflow-y-auto pb-14" className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
> >
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{reviewItems?.length === 0 ? ( {reviewItems?.length === 0 ? (
@ -811,7 +811,7 @@ function ObjectTimeline({
if (!timeline || timeline.length === 0) { if (!timeline || timeline.length === 0) {
return ( return (
<div className="py-2 text-sm text-muted-foreground"> <div className="ml-8 text-sm text-muted-foreground">
{t("detail.noObjectDetailData")} {t("detail.noObjectDetailData")}
</div> </div>
); );

View File

@ -55,20 +55,24 @@ export default function EventMenu({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onSelect={handleObjectSelect}> <DropdownMenuItem
className="cursor-pointer"
onSelect={handleObjectSelect}
>
{isSelected {isSelected
? t("itemMenu.hideObjectDetails.label") ? t("itemMenu.hideObjectDetails.label")
: t("itemMenu.showObjectDetails.label")} : t("itemMenu.showObjectDetails.label")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="my-0.5" /> <DropdownMenuSeparator className="my-0.5" />
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer"
onSelect={() => { onSelect={() => {
navigate(`/explore?event_id=${event.id}`); navigate(`/explore?event_id=${event.id}`);
}} }}
> >
{t("details.item.button.viewInExplore")} {t("details.item.button.viewInExplore")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem className="cursor-pointer" asChild>
<a <a
download download
href={ href={
@ -86,6 +90,7 @@ export default function EventMenu({
event.data.type == "object" && event.data.type == "object" &&
config?.plus?.enabled && ( config?.plus?.enabled && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer"
onSelect={() => { onSelect={() => {
setIsOpen(false); setIsOpen(false);
onOpenUpload?.(event); onOpenUpload?.(event);
@ -97,6 +102,7 @@ export default function EventMenu({
{event.has_snapshot && config?.semantic_search?.enabled && ( {event.has_snapshot && config?.semantic_search?.enabled && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer"
onSelect={() => { onSelect={() => {
if (onOpenSimilarity) onOpenSimilarity(event); if (onOpenSimilarity) onOpenSimilarity(event);
else else

View File

@ -118,6 +118,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>(); const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
const refreshAll = useCallback(() => {
refreshTrain();
refreshDataset();
}, [refreshTrain, refreshDataset]);
// image multiselect // image multiselect
const [selectedImages, setSelectedImages] = useState<string[]>([]); const [selectedImages, setSelectedImages] = useState<string[]>([]);
@ -183,11 +188,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
); );
const onDelete = useCallback( const onDelete = useCallback(
(ids: string[], isName: boolean = false) => { (ids: string[], isName: boolean = false, category?: string) => {
const targetCategory = category || pageToggle;
const api = const api =
pageToggle == "train" targetCategory == "train"
? `/classification/${model.name}/train/delete` ? `/classification/${model.name}/train/delete`
: `/classification/${model.name}/dataset/${pageToggle}/delete`; : `/classification/${model.name}/dataset/${targetCategory}/delete`;
axios axios
.post(api, { ids }) .post(api, { ids })
@ -408,7 +414,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
trainImages={trainImages || []} trainImages={trainImages || []}
trainFilter={trainFilter} trainFilter={trainFilter}
selectedImages={selectedImages} selectedImages={selectedImages}
onRefresh={refreshTrain} onRefresh={refreshAll}
onClickImages={onClickImages} onClickImages={onClickImages}
onDelete={onDelete} onDelete={onDelete}
/> />
@ -432,7 +438,7 @@ type LibrarySelectorProps = {
dataset: { [id: string]: string[] }; dataset: { [id: string]: string[] };
trainImages: string[]; trainImages: string[];
setPageToggle: (toggle: string) => void; setPageToggle: (toggle: string) => void;
onDelete: (ids: string[], isName: boolean) => void; onDelete: (ids: string[], isName: boolean, category?: string) => void;
onRename: (old_name: string, new_name: string) => void; onRename: (old_name: string, new_name: string) => void;
}; };
function LibrarySelector({ function LibrarySelector({
@ -448,7 +454,7 @@ function LibrarySelector({
// data // data
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [renameClass, setRenameFace] = useState<string | null>(null); const [renameClass, setRenameClass] = useState<string | null>(null);
const pageTitle = useMemo(() => { const pageTitle = useMemo(() => {
if (pageToggle != "train") { if (pageToggle != "train") {
return pageToggle; return pageToggle;
@ -463,12 +469,12 @@ function LibrarySelector({
// interaction // interaction
const handleDeleteFace = useCallback( const handleDeleteCategory = useCallback(
(name: string) => { (name: string) => {
// Get all image IDs for this face // Get all image IDs for this category
const imageIds = dataset?.[name] || []; const imageIds = dataset?.[name] || [];
onDelete(imageIds, true); onDelete(imageIds, true, name);
setPageToggle("train"); setPageToggle("train");
}, },
[dataset, onDelete, setPageToggle], [dataset, onDelete, setPageToggle],
@ -476,7 +482,7 @@ function LibrarySelector({
const handleSetOpen = useCallback( const handleSetOpen = useCallback(
(open: boolean) => { (open: boolean) => {
setRenameFace(open ? renameClass : null); setRenameClass(open ? renameClass : null);
}, },
[renameClass], [renameClass],
); );
@ -503,7 +509,7 @@ function LibrarySelector({
className="text-white" className="text-white"
onClick={() => { onClick={() => {
if (confirmDelete) { if (confirmDelete) {
handleDeleteFace(confirmDelete); handleDeleteCategory(confirmDelete);
setConfirmDelete(null); setConfirmDelete(null);
} }
}} }}
@ -521,7 +527,7 @@ function LibrarySelector({
description={t("renameCategory.desc", { name: renameClass })} description={t("renameCategory.desc", { name: renameClass })}
onSave={(newName) => { onSave={(newName) => {
onRename(renameClass!, newName); onRename(renameClass!, newName);
setRenameFace(null); setRenameClass(null);
}} }}
defaultValue={renameClass || ""} defaultValue={renameClass || ""}
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u} regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
@ -588,7 +594,7 @@ function LibrarySelector({
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100" className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setRenameFace(id); setRenameClass(id);
}} }}
> >
<LuPencil className="size-4 text-primary" /> <LuPencil className="size-4 text-primary" />

View File

@ -236,10 +236,6 @@ function ExploreThumbnailImage({
onSelectSearch(event, false, "tracking_details"); onSelectSearch(event, false, "tracking_details");
}; };
const handleShowSnapshot = () => {
onSelectSearch(event, false, "snapshot");
};
const handleAddTrigger = () => { const handleAddTrigger = () => {
navigate( navigate(
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`, `/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
@ -252,7 +248,6 @@ function ExploreThumbnailImage({
findSimilar={handleFindSimilar} findSimilar={handleFindSimilar}
refreshResults={mutate} refreshResults={mutate}
showTrackingDetails={handleShowTrackingDetails} showTrackingDetails={handleShowTrackingDetails}
showSnapshot={handleShowSnapshot}
addTrigger={handleAddTrigger} addTrigger={handleAddTrigger}
isContextMenu={true} isContextMenu={true}
> >

View File

@ -985,7 +985,7 @@ function Timeline({
), ),
)} )}
> >
{isMobile && ( {isMobile && timelineType == "timeline" && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} /> <GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
)} )}

View File

@ -688,9 +688,6 @@ export default function SearchView({
showTrackingDetails={() => showTrackingDetails={() =>
onSelectSearch(value, false, "tracking_details") onSelectSearch(value, false, "tracking_details")
} }
showSnapshot={() =>
onSelectSearch(value, false, "snapshot")
}
addTrigger={() => { addTrigger={() => {
if ( if (
config?.semantic_search.enabled && config?.semantic_search.enabled &&

View File

@ -403,7 +403,8 @@ export default function TriggerView({
setShowCreate(true); setShowCreate(true);
setSelectedTrigger({ setSelectedTrigger({
enabled: true, enabled: true,
name: "", name: eventId,
friendly_name: "",
type: "thumbnail", type: "thumbnail",
data: eventId, data: eventId,
threshold: 0.5, threshold: 0.5,