Compare commits

..

No commits in common. "ef19332fe5c199378f133c96128f603722c99f11" and "a15399fed5b97073dbd9ba793c88b1bfe8d72786" have entirely different histories.

54 changed files with 365 additions and 565 deletions

View File

@ -810,8 +810,6 @@ cameras:
# NOTE: This must be different than any camera names, but can match with another zone on another # NOTE: This must be different than any camera names, but can match with another zone on another
# camera. # camera.
front_steps: front_steps:
# Optional: A friendly name or descriptive text for the zones
friendly_name: ""
# Required: List of x,y coordinates to define the polygon of the zone. # Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428

View File

@ -27,7 +27,6 @@ cameras:
- entire_yard - entire_yard
zones: zones:
entire_yard: entire_yard:
friendly_name: Entire yard # You can use characters from any language text
coordinates: ... coordinates: ...
``` ```
@ -45,10 +44,8 @@ cameras:
- edge_yard - edge_yard
zones: zones:
edge_yard: edge_yard:
friendly_name: Edge yard # You can use characters from any language text
coordinates: ... coordinates: ...
inner_yard: inner_yard:
friendly_name: Inner yard # You can use characters from any language text
coordinates: ... coordinates: ...
``` ```
@ -62,7 +59,6 @@ cameras:
- entire_yard - entire_yard
zones: zones:
entire_yard: entire_yard:
friendly_name: Entire yard
coordinates: ... coordinates: ...
``` ```
@ -86,7 +82,6 @@ cameras:
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street. Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
### Zone Loitering ### Zone Loitering
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone. Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.

View File

@ -662,11 +662,8 @@ 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 images."}), content=({"success": True, "message": "Successfully deleted faces."}),
status_code=200, status_code=200,
) )
@ -726,7 +723,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 categorized image."}), content=({"success": True, "message": "Successfully deleted faces."}),
status_code=200, status_code=200,
) )
@ -764,7 +761,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 images."}), content=({"success": True, "message": "Successfully deleted faces."}),
status_code=200, status_code=200,
) )

View File

@ -13,9 +13,6 @@ logger = logging.getLogger(__name__)
class ZoneConfig(BaseModel): class ZoneConfig(BaseModel):
friendly_name: Optional[str] = Field(
None, title="Zone friendly name used in the Frigate UI."
)
filters: dict[str, FilterConfig] = Field( filters: dict[str, FilterConfig] = Field(
default_factory=dict, title="Zone filters." default_factory=dict, title="Zone filters."
) )

View File

@ -9,6 +9,7 @@ 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 (
@ -31,13 +32,11 @@ 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
@ -129,7 +128,10 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
) )
# Embed the description # Embed the description
self.embeddings.embed_description(event_id, transcription) self.requestor.send_data(
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,9 +226,7 @@ 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( AudioTranscriptionPostProcessor(self.config, self.requestor, metrics)
self.config, self.requestor, self.embeddings, metrics
)
) )
semantic_trigger_processor: SemanticTriggerProcessor | None = None semantic_trigger_processor: SemanticTriggerProcessor | None = None

View File

@ -369,10 +369,6 @@ 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

@ -2,19 +2,12 @@ import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from "@radix-ui/react-label";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CameraConfig } from "@/types/frigateConfig"; import { CameraConfig } from "@/types/frigateConfig";
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
interface CameraNameLabelProps interface CameraNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> { extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
camera?: string | CameraConfig; camera?: string | CameraConfig;
} }
interface ZoneNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
zone: string;
camera?: string;
}
const CameraNameLabel = React.forwardRef< const CameraNameLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
CameraNameLabelProps CameraNameLabelProps
@ -28,17 +21,4 @@ const CameraNameLabel = React.forwardRef<
}); });
CameraNameLabel.displayName = LabelPrimitive.Root.displayName; CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
const ZoneNameLabel = React.forwardRef< export { CameraNameLabel };
React.ElementRef<typeof LabelPrimitive.Root>,
ZoneNameLabelProps
>(({ className, zone, camera, ...props }, ref) => {
const displayName = useZoneFriendlyName(zone, camera);
return (
<LabelPrimitive.Root ref={ref} className={className} {...props}>
{displayName}
</LabelPrimitive.Root>
);
});
ZoneNameLabel.displayName = LabelPrimitive.Root.displayName;
export { CameraNameLabel, ZoneNameLabel };

View File

@ -14,6 +14,7 @@ type SearchThumbnailProps = {
findSimilar: () => void; findSimilar: () => void;
refreshResults: () => void; refreshResults: () => void;
showTrackingDetails: () => void; showTrackingDetails: () => void;
showSnapshot: () => void;
addTrigger: () => void; addTrigger: () => void;
}; };
@ -23,6 +24,7 @@ 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"]);
@ -60,6 +62,7 @@ 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

@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog"; import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role"; import { useIsCustomRole } from "@/hooks/use-is-custom-role";

View File

@ -190,7 +190,7 @@ export function CamerasFilterContent({
key={item} key={item}
isChecked={currentCameras?.includes(item) ?? false} isChecked={currentCameras?.includes(item) ?? false}
label={item} label={item}
type={"camera"} isCameraName={true}
disabled={ disabled={
mainCamera !== undefined && mainCamera !== undefined &&
currentCameras !== undefined && currentCameras !== undefined &&

View File

@ -1,39 +1,29 @@
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type FilterSwitchProps = { type FilterSwitchProps = {
label: string; label: string;
disabled?: boolean; disabled?: boolean;
isChecked: boolean; isChecked: boolean;
isCameraName?: boolean; isCameraName?: boolean;
type?: string;
extraValue?: string;
onCheckedChange: (checked: boolean) => void; onCheckedChange: (checked: boolean) => void;
}; };
export default function FilterSwitch({ export default function FilterSwitch({
label, label,
disabled = false, disabled = false,
isChecked, isChecked,
type = "", isCameraName = false,
extraValue = "",
onCheckedChange, onCheckedChange,
}: FilterSwitchProps) { }: FilterSwitchProps) {
return ( return (
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
{type === "camera" ? ( {isCameraName ? (
<CameraNameLabel <CameraNameLabel
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`} className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
htmlFor={label} htmlFor={label}
camera={label} camera={label}
/> />
) : type === "zone" ? (
<ZoneNameLabel
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
htmlFor={label}
camera={extraValue}
zone={label}
/>
) : ( ) : (
<Label <Label
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`} className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}

View File

@ -550,8 +550,7 @@ export function GeneralFilterContent({
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={item} label={item.replaceAll("_", " ")}
type={"zone"}
isChecked={filter.zones?.includes(item) ?? false} isChecked={filter.zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -53,7 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md"; import { MdImageSearch } from "react-icons/md";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type InputWithTagsProps = { type InputWithTagsProps = {
inputFocused: boolean; inputFocused: boolean;
@ -831,8 +831,6 @@ export default function InputWithTags({
getTranslatedLabel(value) getTranslatedLabel(value)
) : filterType === "cameras" ? ( ) : filterType === "cameras" ? (
<CameraNameLabel camera={value} /> <CameraNameLabel camera={value} />
) : filterType === "zones" ? (
<ZoneNameLabel zone={value} />
) : ( ) : (
value.replaceAll("_", " ") value.replaceAll("_", " ")
)} )}
@ -936,11 +934,6 @@ export default function InputWithTags({
<CameraNameLabel camera={suggestion} /> <CameraNameLabel camera={suggestion} />
{")"} {")"}
</> </>
) : currentFilterType === "zones" ? (
<>
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
{")"}
</>
) : ( ) : (
suggestion suggestion
) )
@ -950,8 +943,6 @@ export default function InputWithTags({
{currentFilterType ? ( {currentFilterType ? (
currentFilterType === "cameras" ? ( currentFilterType === "cameras" ? (
<CameraNameLabel camera={suggestion} /> <CameraNameLabel camera={suggestion} />
) : currentFilterType === "zones" ? (
<ZoneNameLabel zone={suggestion} />
) : ( ) : (
formatFilterValues(currentFilterType, suggestion) formatFilterValues(currentFilterType, suggestion)
) )

View File

@ -47,7 +47,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;

View File

@ -6,7 +6,10 @@ 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,
@ -30,18 +33,23 @@ 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;
@ -52,6 +60,7 @@ export default function SearchResultActions({
findSimilar, findSimilar,
refreshResults, refreshResults,
showTrackingDetails, showTrackingDetails,
showSnapshot,
addTrigger, addTrigger,
isContextMenu = false, isContextMenu = false,
children, children,
@ -120,7 +129,7 @@ export default function SearchResultActions({
aria-label={t("itemMenu.viewTrackingDetails.aria")} aria-label={t("itemMenu.viewTrackingDetails.aria")}
onClick={showTrackingDetails} onClick={showTrackingDetails}
> >
<PiPath className="mr-2 size-4" /> <FaArrowsRotate className="mr-2 size-4" />
<span>{t("itemMenu.viewTrackingDetails.label")}</span> <span>{t("itemMenu.viewTrackingDetails.label")}</span>
</MenuItem> </MenuItem>
)} )}
@ -143,14 +152,18 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span> <span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem> </MenuItem>
)} )}
{config?.semantic_search?.enabled && {isMobileOnly &&
searchResult.data.type == "object" && ( config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
<MenuItem <MenuItem
aria-label={t("itemMenu.findSimilar.aria")} aria-label={t("itemMenu.submitToPlus.aria")}
onClick={findSimilar} onClick={showSnapshot}
> >
<MdImageSearch className="mr-2 size-4" /> <FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>{t("itemMenu.findSimilar.label")}</span> <span>{t("itemMenu.submitToPlus.label")}</span>
</MenuItem> </MenuItem>
)} )}
<MenuItem <MenuItem
@ -198,6 +211,44 @@ 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,7 +4,6 @@ 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";
@ -122,20 +121,17 @@ 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) {
@ -144,27 +140,15 @@ 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,12 +97,14 @@ export default function ClassificationSelectionDialog({
return ( return (
<div className={className ?? "flex"}> <div className={className ?? "flex"}>
<TextEntryDialog {newClass && (
open={newClass} <TextEntryDialog
setOpen={setNewClass} open={true}
title={t("createCategory.new")} setOpen={setNewClass}
onSave={(newCat) => onCategorizeImage(newCat)} title={t("createCategory.new")}
/> onSave={(newCat) => onCategorizeImage(newCat)}
/>
)}
<Tooltip> <Tooltip>
<Selector> <Selector>

View File

@ -25,7 +25,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {

View File

@ -24,7 +24,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type EditRoleCamerasOverlayProps = { type EditRoleCamerasOverlayProps = {
show: boolean; show: boolean;

View File

@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/CameraNameLabel";
type MobileCameraDrawerProps = { type MobileCameraDrawerProps = {
allCameras: string[]; allCameras: string[];

View File

@ -12,7 +12,6 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues // Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01; const TOLERANCE = 0.01;
@ -115,10 +114,6 @@ export default function ObjectTrackOverlay({
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
};
const timelineResults = useMemo(() => { const timelineResults = useMemo(() => {
// Group timeline entries by source_id // Group timeline entries by source_id
if (!timelineData) return selectedObjectIds.map(() => []); if (!timelineData) return selectedObjectIds.map(() => []);
@ -132,19 +127,8 @@ export default function ObjectTrackOverlay({
} }
// Return timeline arrays in the same order as selectedObjectIds // Return timeline arrays in the same order as selectedObjectIds
return selectedObjectIds.map((id) => { return selectedObjectIds.map((id) => grouped[id] || []);
const entries = grouped[id] || []; }, [selectedObjectIds, timelineData]);
return entries.map((event) => ({
...event,
data: {
...event.data,
zones_friendly_names: config
? getZonesFriendlyNames(event.data?.zones, config)
: [],
},
}));
});
}, [selectedObjectIds, timelineData, config]);
const typeColorMap = useMemo( const typeColorMap = useMemo(
() => ({ () => ({

View File

@ -141,52 +141,50 @@ 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-3" className="flex flex-1 flex-col space-y-6"
> >
<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">
<FormItem className="flex flex-row items-start justify-between space-x-2"> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <FormLabel>
<FormLabel> {t("trackingDetails.annotationSettings.offset.label")}
{t("trackingDetails.annotationSettings.offset.label")} </FormLabel>
</FormLabel> <FormDescription>
<FormDescription> <Trans ns="views/explore">
<Trans ns="views/explore"> trackingDetails.annotationSettings.offset.millisecondsToOffset
trackingDetails.annotationSettings.offset.millisecondsToOffset </Trans>
</Trans> <FormMessage />
<FormMessage /> <div className="mt-2">
</FormDescription> {t("trackingDetails.annotationSettings.offset.tips")}
</div> <div className="mt-2 flex items-center text-primary">
<div className="flex flex-col gap-3"> <Link
<div className="min-w-24"> to={getLocaleDocUrl("configuration/reference")}
<FormControl> target="_blank"
<Input rel="noopener noreferrer"
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]" className="inline"
placeholder="0" >
{...field} {t("readTheDocumentation", { ns: "common" })}
/> <LuExternalLink className="ml-2 inline-flex size-3" />
</FormControl> </Link>
</div>
</div> </div>
</div> </FormDescription>
</FormItem> </div>
<div className="mt-1 text-sm text-secondary-foreground"> <div className="flex flex-col gap-3">
{t("trackingDetails.annotationSettings.offset.tips")} <div className="min-w-24">
<div className="mt-2 flex items-center text-primary-variant"> <FormControl>
<Link <Input
to={getLocaleDocUrl("configuration/reference")} 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]"
target="_blank" placeholder="0"
rel="noopener noreferrer" {...field}
className="inline" />
> </FormControl>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div> </div>
</div> </div>
</> </FormItem>
)} )}
/> />

View File

@ -111,23 +111,6 @@ 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

@ -8,9 +8,6 @@ import {
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
type ObjectPathProps = { type ObjectPathProps = {
positions?: Position[]; positions?: Position[];
@ -45,31 +42,16 @@ export function ObjectPath({
visible = true, visible = true,
}: ObjectPathProps) { }: ObjectPathProps) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config");
const getAbsolutePositions = useCallback(() => { const getAbsolutePositions = useCallback(() => {
if (!imgRef.current || !positions) return []; if (!imgRef.current || !positions) return [];
const imgRect = imgRef.current.getBoundingClientRect(); const imgRect = imgRef.current.getBoundingClientRect();
return positions.map((pos) => { return positions.map((pos) => ({
return { x: pos.x * imgRect.width,
x: pos.x * imgRect.width, y: pos.y * imgRect.height,
y: pos.y * imgRect.height, timestamp: pos.timestamp,
timestamp: pos.timestamp, lifecycle_item: pos.lifecycle_item,
lifecycle_item: pos.lifecycle_item?.data?.zones }));
? { }, [positions, imgRef]);
...pos.lifecycle_item,
data: {
...pos.lifecycle_item?.data,
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
(zone) => {
return resolveZoneName(config, zone);
},
),
},
}
: pos.lifecycle_item,
};
});
}, [imgRef, positions, config]);
const generateStraightPath = useCallback((points: Position[]) => { const generateStraightPath = useCallback((points: Position[]) => {
if (!points || points.length < 2) return ""; if (!points || points.length < 2) return "";

View File

@ -80,7 +80,7 @@ import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { DialogPortal } from "@radix-ui/react-dialog"; import { DialogPortal } from "@radix-ui/react-dialog";
import { useDetailStream } from "@/context/detail-stream-context"; import { useDetailStream } from "@/context/detail-stream-context";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";
@ -1242,110 +1242,106 @@ function ObjectDetailsTab({
</div> </div>
</div> </div>
{search.data.type === "object" && <div
!search.plus_id && className={cn(
config?.plus?.enabled && ( "my-2 flex w-full flex-col justify-between gap-1.5",
<div state == "submitted" && "flex-row",
className={cn( )}
"my-2 flex w-full flex-col justify-between gap-1.5", >
state == "submitted" && "flex-row", <div className="text-sm text-primary/40">
)} <div className="flex flex-row items-center gap-1">
> {t("explore.plus.submitToPlus.label", {
<div className="text-sm text-primary/40"> ns: "components/dialog",
<div className="flex flex-row items-center gap-1"> })}
{t("explore.plus.submitToPlus.label", { <Popover>
<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",
})} })}
<Popover> </PopoverContent>
<PopoverTrigger asChild> </Popover>
<div className="cursor-pointer p-0"> </div>
<LuInfo className="size-4" /> </div>
<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
ns="components/dialog"
values={{ label: search?.label }}
>
explore.plus.review.question.ask_an
</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={{ values={{ label: search?.label }}
untranslatedLabel: search?.label,
translatedLabel: getTranslatedLabel(search?.label),
}}
> >
explore.plus.review.question.ask_full explore.plus.review.question.ask_an
</Trans>
) : (
<Trans
ns="components/dialog"
values={{ label: search?.label }}
>
explore.plus.review.question.ask_a
</Trans> </Trans>
)} )}
</div> </>
<div className="flex max-w-xl flex-row gap-2"> ) : (
<Button // For other languages
className="flex-1 bg-success" <Trans
aria-label={t("button.yes", { ns: "common" })} ns="components/dialog"
onClick={() => { values={{
setState("uploading"); untranslatedLabel: search?.label,
onSubmitToPlus(false); translatedLabel: getTranslatedLabel(search?.label),
}} }}
> >
{t("button.yes", { ns: "common" })} explore.plus.review.question.ask_full
</Button> </Trans>
<Button )}
className="flex-1 text-white" </div>
aria-label={t("button.no", { ns: "common" })} <div className="flex max-w-xl flex-row gap-2">
variant="destructive" <Button
onClick={() => { className="flex-1 bg-success"
setState("uploading"); aria-label={t("button.yes", { ns: "common" })}
onSubmitToPlus(true); onClick={() => {
}} setState("uploading");
> onSubmitToPlus(false);
{t("button.no", { ns: "common" })} }}
</Button> >
</div> {t("button.yes", { ns: "common" })}
</> </Button>
)} <Button
{state == "uploading" && <ActivityIndicator />} className="flex-1 text-white"
{state == "submitted" && ( aria-label={t("button.no", { ns: "common" })}
<div className="flex flex-row items-center justify-center gap-2"> variant="destructive"
<FaCheckCircle className="size-4 text-success" /> onClick={() => {
{t("explore.plus.review.state.submitted")} setState("uploading");
</div> onSubmitToPlus(true);
)} }}
>
{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

@ -23,7 +23,6 @@ import { Link, useNavigate } from "react-router-dom";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios"; import axios from "axios";
@ -74,12 +73,6 @@ export function TrackingDetails({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
eventSequence?.map((event) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone);
});
});
// Use manualOverride (set when seeking in image mode) if present so // Use manualOverride (set when seeking in image mode) if present so
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall // lifecycle rows and overlays follow image-mode seeks. Otherwise fall
// back to currentTime used for video mode. // back to currentTime used for video mode.
@ -464,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 ml-3 text-sm text-danger"> <div className="mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")} {t("trackingDetails.autoTrackingTips")}
</div> </div>
)} )}
@ -720,8 +713,7 @@ function LifecycleIconRow({
}} }}
/> />
<span className="smart-capitalize"> <span className="smart-capitalize">
{item.data?.zones_friendly_names?.[zidx] ?? {zone.replaceAll("_", " ")}
zone.replaceAll("_", " ")}
</span> </span>
</Badge> </Badge>
); );

View File

@ -20,9 +20,7 @@ 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;
@ -81,8 +79,6 @@ 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}>
@ -90,22 +86,14 @@ export function PlatformAwareSheet({
{trigger} {trigger}
</MobilePageTrigger> </MobilePageTrigger>
<MobilePagePortal> <MobilePagePortal>
<MobilePageContent <MobilePageContent className="h-full overflow-hidden">
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 <div className={contentClassName}>{content}</div>
ref={scrollerRef}
className={cn("flex-1 overflow-y-auto", contentClassName)}
>
{content}
</div>
</MobilePageContent> </MobilePageContent>
</MobilePagePortal> </MobilePagePortal>
</MobilePage> </MobilePage>

View File

@ -98,11 +98,7 @@ export default function RestartDialog({
open={restartingSheetOpen} open={restartingSheetOpen}
onOpenChange={() => setRestartingSheetOpen(false)} onOpenChange={() => setRestartingSheetOpen(false)}
> >
<SheetContent <SheetContent side="top" onInteractOutside={(e) => e.preventDefault()}>
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,7 +230,6 @@ 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",
@ -430,8 +429,7 @@ export function ZoneFilterContent({
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={item} label={item.replaceAll("_", " ")}
type={"zone"}
isChecked={zones?.includes(item) ?? false} isChecked={zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -262,17 +262,13 @@ export function PolygonCanvas({
}; };
useEffect(() => { useEffect(() => {
if (activePolygonIndex === undefined || !polygons?.length) { if (activePolygonIndex === undefined || !polygons) {
return; return;
} }
const updatedPolygons = [...polygons]; const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex]; const activePolygon = updatedPolygons[activePolygonIndex];
if (!activePolygon) {
return;
}
// add default points order for already completed polygons // add default points order for already completed polygons
if (!activePolygon.pointsOrder && activePolygon.isFinished) { if (!activePolygon.pointsOrder && activePolygon.isFinished) {
updatedPolygons[activePolygonIndex] = { updatedPolygons[activePolygonIndex] = {

View File

@ -179,7 +179,7 @@ export default function PolygonItem({
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", { t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name, name: polygon?.name,
}), }),
{ {
position: "top-center", position: "top-center",
@ -261,9 +261,7 @@ export default function PolygonItem({
}} }}
/> />
)} )}
<p className="cursor-default"> <p className="cursor-default">{polygon.name}</p>
{polygon.friendly_name ?? polygon.name}
</p>
</div> </div>
<AlertDialog <AlertDialog
open={deleteDialogOpen} open={deleteDialogOpen}
@ -280,7 +278,7 @@ export default function PolygonItem({
ns="views/settings" ns="views/settings"
values={{ values={{
type: polygon.type.replace("_", " "), type: polygon.type.replace("_", " "),
name: polygon.friendly_name ?? polygon.name, name: polygon.name,
}} }}
> >
masksAndZones.form.polygonDrawing.delete.desc masksAndZones.form.polygonDrawing.delete.desc

View File

@ -34,7 +34,6 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -147,37 +146,15 @@ export default function ZoneEditPane({
"masksAndZones.form.zoneName.error.mustNotContainPeriod", "masksAndZones.form.zoneName.error.mustNotContainPeriod",
), ),
}, },
),
friendly_name: z
.string()
.min(2, {
message: t(
"masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
),
})
.refine(
(value: string) => {
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: t(
"masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
),
},
) )
.refine( .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
(value: string) => { message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
const otherPolygonNames = })
polygons .refine((value: string) => /[a-zA-Z]/.test(value), {
?.filter((_, index) => index !== activePolygonIndex) message: t(
.map((polygon) => polygon.name) || []; "masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
),
return !otherPolygonNames.includes(value); }),
},
{
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
},
),
inertia: z.coerce inertia: z.coerce
.number() .number()
.min(1, { .min(1, {
@ -270,7 +247,6 @@ export default function ZoneEditPane({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
name: polygon?.name ?? "", name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
inertia: inertia:
polygon?.camera && polygon?.camera &&
polygon?.name && polygon?.name &&
@ -310,7 +286,6 @@ export default function ZoneEditPane({
async ( async (
{ {
name: zoneName, name: zoneName,
friendly_name,
inertia, inertia,
loitering_time, loitering_time,
objects: form_objects, objects: form_objects,
@ -440,14 +415,9 @@ export default function ZoneEditPane({
} }
} }
let friendlyNameQuery = "";
if (friendly_name) {
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
}
axios axios
.put( .put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ {
requires_restart: 0, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`, update_topic: `config/cameras/${polygon.camera}/zones`,
@ -457,7 +427,7 @@ export default function ZoneEditPane({
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
t("masksAndZones.zones.toast.success", { t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName, zoneName,
}), }),
{ {
position: "top-center", position: "top-center",
@ -571,16 +541,26 @@ export default function ZoneEditPane({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
<NameAndIdFields <FormField
type="zone"
control={form.control} control={form.control}
nameField="friendly_name" name="name"
idField="name" render={({ field }) => (
nameLabel={t("masksAndZones.zones.name.title")} <FormItem>
nameDescription={t("masksAndZones.zones.name.tips")} <FormLabel>{t("masksAndZones.zones.name.title")}</FormLabel>
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")} <FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder={t("masksAndZones.zones.name.inputPlaceHolder")}
{...field}
/>
</FormControl>
<FormDescription>
{t("masksAndZones.zones.name.tips")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/> />
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<FormField <FormField
control={form.control} control={form.control}

View File

@ -26,7 +26,6 @@ import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { PiSlidersHorizontalBold } from "react-icons/pi"; import { PiSlidersHorizontalBold } from "react-icons/pi";
import { MdAutoAwesome } from "react-icons/md"; import { MdAutoAwesome } from "react-icons/md";
@ -193,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 overflow-x-hidden pb-14" className="scrollbar-container flex-1 overflow-y-auto pb-14"
> >
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{reviewItems?.length === 0 ? ( {reviewItems?.length === 0 ? (
@ -794,28 +793,17 @@ function ObjectTimeline({
}, },
]); ]);
const { data: config } = useSWR<FrigateConfig>("config");
const timeline = useMemo(() => { const timeline = useMemo(() => {
if (!fullTimeline) { if (!fullTimeline) {
return fullTimeline; return fullTimeline;
} }
return fullTimeline return fullTimeline.filter(
.filter( (t) =>
(t) => t.timestamp >= review.start_time &&
t.timestamp >= review.start_time && (review.end_time == undefined || t.timestamp <= review.end_time),
(review.end_time == undefined || t.timestamp <= review.end_time), );
) }, [fullTimeline, review]);
.map((event) => ({
...event,
data: {
...event.data,
zones_friendly_names: event.data?.zones?.map((zone) =>
resolveZoneName(config, zone),
),
},
}));
}, [config, fullTimeline, review]);
if (isValidating && (!timeline || timeline.length === 0)) { if (isValidating && (!timeline || timeline.length === 0)) {
return <ActivityIndicator className="ml-2 size-3" />; return <ActivityIndicator className="ml-2 size-3" />;
@ -823,7 +811,7 @@ function ObjectTimeline({
if (!timeline || timeline.length === 0) { if (!timeline || timeline.length === 0) {
return ( return (
<div className="ml-8 text-sm text-muted-foreground"> <div className="py-2 text-sm text-muted-foreground">
{t("detail.noObjectDetailData")} {t("detail.noObjectDetailData")}
</div> </div>
); );

View File

@ -55,24 +55,20 @@ export default function EventMenu({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem onSelect={handleObjectSelect}>
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 className="cursor-pointer" asChild> <DropdownMenuItem asChild>
<a <a
download download
href={ href={
@ -90,7 +86,6 @@ 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);
@ -102,7 +97,6 @@ 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

@ -1,41 +0,0 @@
import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo } from "react";
import useSWR from "swr";
export function resolveZoneName(
config: FrigateConfig | undefined,
zoneId: string,
cameraId?: string,
) {
if (!config) return String(zoneId).replace(/_/g, " ");
if (cameraId) {
const camera = config.cameras?.[String(cameraId)];
const zone = camera?.zones?.[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
}
for (const camKey in config.cameras) {
if (!Object.prototype.hasOwnProperty.call(config.cameras, camKey)) continue;
const cam = config.cameras[camKey];
if (!cam?.zones) continue;
if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) {
const zone = cam.zones[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
}
}
// Fallback: return a cleaned-up zoneId string
return String(zoneId).replace(/_/g, " ");
}
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
const { data: config } = useSWR<FrigateConfig>("config");
const name = useMemo(
() => resolveZoneName(config, zoneId, cameraId),
[config, cameraId, zoneId],
);
return name;
}

View File

@ -42,7 +42,7 @@ import { useInitialCameraState } from "@/api/ws";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TriggerView from "@/views/settings/TriggerView"; import TriggerView from "@/views/settings/TriggerView";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -650,7 +650,7 @@ function CameraSelectButton({
key={item.name} key={item.name}
isChecked={item.name === selectedCamera} isChecked={item.name === selectedCamera}
label={item.name} label={item.name}
type={"camera"} isCameraName={true}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) { if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name); setSelectedCamera(item.name);

View File

@ -11,12 +11,10 @@ export type Polygon = {
distances: number[]; distances: number[];
isFinished: boolean; isFinished: boolean;
color: number[]; color: number[];
friendly_name?: string;
}; };
export type ZoneFormValuesType = { export type ZoneFormValuesType = {
name: string; name: string;
friendly_name: string;
inertia: number; inertia: number;
loitering_time: number; loitering_time: number;
isFinished: boolean; isFinished: boolean;

View File

@ -280,7 +280,6 @@ export interface CameraConfig {
speed_threshold: number; speed_threshold: number;
objects: string[]; objects: string[];
color: number[]; color: number[];
friendly_name?: string;
}; };
}; };
} }

View File

@ -22,7 +22,6 @@ export type TrackingDetailsSequence = {
attribute: string; attribute: string;
attribute_box?: [number, number, number, number]; attribute_box?: [number, number, number, number];
zones: string[]; zones: string[];
zones_friendly_names?: string[];
}; };
class_type: LifecycleClassType; class_type: LifecycleClassType;
source_id: string; source_id: string;

View File

@ -1,7 +1,25 @@
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
import { t } from "i18next"; import { t } from "i18next";
import { getTranslatedLabel } from "./i18n"; import { getTranslatedLabel } from "./i18n";
import { capitalizeFirstLetter, formatList } from "./stringUtil"; import { capitalizeFirstLetter } from "./stringUtil";
function formatZonesList(zones: string[]): string {
if (zones.length === 0) return "";
if (zones.length === 1) return zones[0];
if (zones.length === 2) {
return t("list.two", {
0: zones[0],
1: zones[1],
});
}
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
return t("list.many", {
items: allButLast,
last: zones[zones.length - 1],
});
}
export function getLifecycleItemDescription( export function getLifecycleItemDescription(
lifecycleItem: TrackingDetailsSequence, lifecycleItem: TrackingDetailsSequence,
@ -24,9 +42,7 @@ export function getLifecycleItemDescription(
return t("trackingDetails.lifecycleItemDesc.entered_zone", { return t("trackingDetails.lifecycleItemDesc.entered_zone", {
ns: "views/explore", ns: "views/explore",
label, label,
zones: formatList( zones: formatZonesList(lifecycleItem.data.zones),
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
),
}); });
case "active": case "active":
return t("trackingDetails.lifecycleItemDesc.active", { return t("trackingDetails.lifecycleItemDesc.active", {

View File

@ -1,5 +1,3 @@
import { t } from "i18next";
export const capitalizeFirstLetter = (text: string): string => { export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
}; };
@ -47,29 +45,3 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
export function isValidId(name: string): boolean { export function isValidId(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name); return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
} }
/**
* Formats a list of strings into a human-readable format with proper localization.
* Handles different cases for empty, single-item, two-item, and multi-item lists.
*
* @param item - The array of strings to format
* @returns A formatted string representation of the list
*/
export function formatList(item: string[]): string {
if (item.length === 0) return "";
if (item.length === 1) return item[0];
if (item.length === 2) {
return t("list.two", {
0: item[0],
1: item[1],
ns: "common",
});
}
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
const allButLast = item.slice(0, -1).join(separatorWithSpace);
return t("list.many", {
items: allButLast,
last: item[item.length - 1],
});
}

View File

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

View File

@ -236,6 +236,10 @@ 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}`,
@ -248,6 +252,7 @@ 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

@ -63,7 +63,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { DetailStreamProvider } from "@/context/detail-stream-context"; import { DetailStreamProvider } from "@/context/detail-stream-context";
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
@ -985,7 +985,7 @@ function Timeline({
), ),
)} )}
> >
{isMobile && timelineType == "timeline" && ( {isMobile && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} /> <GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
)} )}

View File

@ -688,6 +688,9 @@ 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

@ -36,7 +36,7 @@ import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog"; import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type AuthenticationViewProps = { type AuthenticationViewProps = {
section?: "users" | "roles"; section?: "users" | "roles";

View File

@ -18,7 +18,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";

View File

@ -23,6 +23,7 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import axios from "axios"; import axios from "axios";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
@ -41,8 +42,6 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { formatList } from "@/utils/stringUtil";
type CameraSettingsViewProps = { type CameraSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
@ -87,47 +86,40 @@ export default function CameraSettingsView({
// zones and labels // zones and labels
const getZoneName = useCallback(
(zoneId: string, cameraId?: string) =>
resolveZoneName(config, zoneId, cameraId),
[config],
);
const zones = useMemo(() => { const zones = useMemo(() => {
if (cameraConfig) { if (cameraConfig) {
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
friendly_name: getZoneName(name, cameraConfig.name),
objects: zoneData.objects, objects: zoneData.objects,
color: zoneData.color, color: zoneData.color,
})); }));
} }
}, [cameraConfig, getZoneName]); }, [cameraConfig]);
const alertsLabels = useMemo(() => { const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels return cameraConfig?.review.alerts.labels
? formatList( ? cameraConfig.review.alerts.labels
cameraConfig.review.alerts.labels.map((label) => .map((label) =>
getTranslatedLabel( getTranslatedLabel(
label, label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
), ),
), )
) .join(", ")
: ""; : "";
}, [cameraConfig]); }, [cameraConfig]);
const detectionsLabels = useMemo(() => { const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels return cameraConfig?.review.detections.labels
? formatList( ? cameraConfig.review.detections.labels
cameraConfig.review.detections.labels.map((label) => .map((label) =>
getTranslatedLabel( getTranslatedLabel(
label, label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
), ),
), )
) .join(", ")
: ""; : "";
}, [cameraConfig]); }, [cameraConfig]);
@ -534,7 +526,7 @@ export default function CameraSettingsView({
/> />
</FormControl> </FormControl>
<FormLabel className="font-normal smart-capitalize"> <FormLabel className="font-normal smart-capitalize">
{zone.friendly_name} {zone.name.replaceAll("_", " ")}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}
@ -556,11 +548,14 @@ export default function CameraSettingsView({
"cameraReview.reviewClassification.zoneObjectAlertsTips", "cameraReview.reviewClassification.zoneObjectAlertsTips",
{ {
alertsLabels, alertsLabels,
zone: formatList( zone: watchedAlertsZones
watchedAlertsZones.map((zone) => .map((zone) =>
getZoneName(zone), capitalizeFirstLetter(zone).replaceAll(
), "_",
), " ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}, },
) )
@ -633,7 +628,7 @@ export default function CameraSettingsView({
/> />
</FormControl> </FormControl>
<FormLabel className="font-normal smart-capitalize"> <FormLabel className="font-normal smart-capitalize">
{zone.friendly_name} {zone.name.replaceAll("_", " ")}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}
@ -672,11 +667,14 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: formatList( zone: watchedDetectionsZones
watchedDetectionsZones.map((zone) => .map((zone) =>
getZoneName(zone), capitalizeFirstLetter(zone).replaceAll(
), "_",
), " ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}} }}
ns="views/settings" ns="views/settings"
@ -686,11 +684,14 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: formatList( zone: watchedDetectionsZones
watchedDetectionsZones.map((zone) => .map((zone) =>
getZoneName(zone), capitalizeFirstLetter(zone).replaceAll(
), "_",
), " ",
),
)
.join(", "),
cameraName: selectCameraName, cameraName: selectCameraName,
}} }}
ns="views/settings" ns="views/settings"

View File

@ -23,7 +23,7 @@ import {
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type FrigatePlusModel = { type FrigatePlusModel = {
id: string; id: string;

View File

@ -229,7 +229,6 @@ export default function MasksAndZonesView({
typeIndex: index, typeIndex: index,
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
friendly_name: zoneData.friendly_name,
objects: zoneData.objects, objects: zoneData.objects,
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(zoneData.coordinates), parseCoordinates(zoneData.coordinates),

View File

@ -45,7 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -476,7 +476,7 @@ export default function NotificationView({
<FilterSwitch <FilterSwitch
key={camera.name} key={camera.name}
label={camera.name} label={camera.name}
type={"camera"} isCameraName={true}
isChecked={field.value?.includes( isChecked={field.value?.includes(
camera.name, camera.name,
)} )}

View File

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

View File

@ -13,7 +13,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import useSWR from "swr"; import useSWR from "swr";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
type CameraMetricsProps = { type CameraMetricsProps = {