Compare commits

...
20 Commits
Author SHA1 Message Date
Josh Hawkins 35cca5bd4b fix title classes on desktop 2025-11-07 07:48:22 -06:00
Josh Hawkins b93dcf151d 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
2025-11-07 07:45:07 -06:00
Nicolas Mowen b1eca87cb5 Skip azure execution provider 2025-11-07 06:42:33 -07:00
Josh Hawkins 4ab9fd114f 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
2025-11-07 07:09:12 -06:00
Josh Hawkins ab43a33d19 move find similar to actions menu 2025-11-06 21:24:11 -06:00
Josh Hawkins f3c0c8b61a add trigger to detail actions menu 2025-11-06 18:58:22 -06:00
Josh Hawkins 19e36fc56b ensure name is valid for search effect trigger 2025-11-06 18:54:18 -06:00
Josh Hawkins 6258b90e91 prevent x overflow in detail stream on mobile safari 2025-11-06 18:46:02 -06:00
Josh Hawkins 1d94b24cfe hide x icon on restart sheet to prevent closure issues 2025-11-06 18:02:56 -06:00
Josh Hawkins 294e04adb1 spacing 2025-11-06 17:53:43 -06:00
Josh Hawkins d4af7f0966 fix audio transcription embedding 2025-11-06 17:38:34 -06:00
Nicolas Mowen 8ddf3ac72e Only show the genai summary popup on mobile when timeline is open 2025-11-06 16:15:38 -07:00
Nicolas Mowen 5b7fe10caf Fix dialog getting stuck 2025-11-06 16:03:36 -07:00
Nicolas Mowen d8e32b8724 Ensure after creating a class that things are correct 2025-11-06 15:57:42 -07:00
Nicolas Mowen ca91d3d82b Fix deletion of classification images and library 2025-11-06 15:55:05 -07:00
Josh Hawkins 88b6bd7535 tweak spacing in annotation settings popover 2025-11-06 16:29:27 -06:00
Josh Hawkins 9f73145d63 don't show submit to plus for non-objects and if plus is disabled 2025-11-06 16:25:50 -06:00
Josh Hawkins f2e3579030 pointer cursor on event menu items in detail stream 2025-11-06 16:22:35 -06:00
Josh Hawkins 796a12bf10 add margin 2025-11-06 16:16:41 -06:00
Josh Hawkins aa6cd1ec05 remove frigate+ icon from explore grid footer 2025-11-06 16:16:02 -06:00
22 changed files with 249 additions and 237 deletions
+6 -3
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,
) )
@@ -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")
+3 -1
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
+4
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({})
@@ -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>
@@ -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")}>
+17 -1
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",
@@ -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>
@@ -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> </>
)} )}
/> />
@@ -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>
@@ -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 &&
@@ -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>
)} )}
@@ -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>
@@ -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">
@@ -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",
+2 -2
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>
); );
+8 -2
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
@@ -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" />
-5
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}
> >
+1 -1
View File
@@ -985,7 +985,7 @@ function Timeline({
), ),
)} )}
> >
{isMobile && ( {isMobile && timelineType == "timeline" && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} /> <GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
)} )}
-3
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 &&
+2 -1
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,