mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-28 22:59:02 +03:00
Compare commits
6
Commits
dev
...
b3a94f2a4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a94f2a4d | ||
|
|
17e89da6ca | ||
|
|
450623e927 | ||
|
|
6a72a041d6 | ||
|
|
23aba70819 | ||
|
|
36b2043518 |
@@ -13,6 +13,9 @@ 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."
|
||||||
)
|
)
|
||||||
|
|||||||
+21
-1
@@ -2,12 +2,19 @@ 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
|
||||||
@@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef<
|
|||||||
});
|
});
|
||||||
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
|
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { CameraNameLabel };
|
const ZoneNameLabel = React.forwardRef<
|
||||||
|
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 };
|
||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
isCameraName={true}
|
type="camera"
|
||||||
disabled={
|
disabled={
|
||||||
mainCamera !== undefined &&
|
mainCamera !== undefined &&
|
||||||
currentCameras !== undefined &&
|
currentCameras !== undefined &&
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
import { Switch } from "../ui/switch";
|
import { Switch } from "../ui/switch";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type FilterSwitchProps = {
|
type FilterSwitchProps = {
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isChecked: boolean;
|
isChecked: 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,
|
||||||
isCameraName = false,
|
type = "",
|
||||||
|
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">
|
||||||
{isCameraName ? (
|
{type === "camera" ? (
|
||||||
<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" : ""}`}
|
||||||
|
|||||||
@@ -552,7 +552,8 @@ export function GeneralFilterContent({
|
|||||||
{allZones.map((item) => (
|
{allZones.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item}
|
key={item}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item}
|
||||||
|
type={"zone"}
|
||||||
isChecked={filter.zones?.includes(item) ?? false}
|
isChecked={filter.zones?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
|
|||||||
@@ -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 } from "../camera/CameraNameLabel";
|
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type InputWithTagsProps = {
|
type InputWithTagsProps = {
|
||||||
inputFocused: boolean;
|
inputFocused: boolean;
|
||||||
@@ -831,6 +831,8 @@ 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("_", " ")
|
||||||
)}
|
)}
|
||||||
@@ -934,6 +936,11 @@ export default function InputWithTags({
|
|||||||
<CameraNameLabel camera={suggestion} />
|
<CameraNameLabel camera={suggestion} />
|
||||||
{")"}
|
{")"}
|
||||||
</>
|
</>
|
||||||
|
) : currentFilterType === "zones" ? (
|
||||||
|
<>
|
||||||
|
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
|
||||||
|
{")"}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
suggestion
|
suggestion
|
||||||
)
|
)
|
||||||
@@ -943,6 +950,8 @@ 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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type LiveContextMenuProps = {
|
type LiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
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 {
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type EditRoleCamerasOverlayProps = {
|
type EditRoleCamerasOverlayProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type MobileCameraDrawerProps = {
|
type MobileCameraDrawerProps = {
|
||||||
allCameras: string[];
|
allCameras: string[];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
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 { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
|
||||||
type ObjectTrackOverlayProps = {
|
type ObjectTrackOverlayProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
@@ -127,6 +128,9 @@ export default function ObjectTrackOverlay({
|
|||||||
?.filter((event) => event.data.box !== undefined)
|
?.filter((event) => event.data.box !== undefined)
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const [left, top, width, height] = event.data.box!;
|
const [left, top, width, height] = event.data.box!;
|
||||||
|
event.data.zones_friendly_names = event?.data?.zones?.map((zone) => {
|
||||||
|
return resolveZoneName(config, zone);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: left + width / 2, // Center x
|
x: left + width / 2, // Center x
|
||||||
@@ -136,7 +140,7 @@ export default function ObjectTrackOverlay({
|
|||||||
};
|
};
|
||||||
}) || []
|
}) || []
|
||||||
);
|
);
|
||||||
}, [objectTimeline]);
|
}, [config, objectTimeline]);
|
||||||
|
|
||||||
// final object path with timeline points included
|
// final object path with timeline points included
|
||||||
const pathPoints = useMemo(() => {
|
const pathPoints = useMemo(() => {
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { ObjectPath } from "./ObjectPath";
|
import { ObjectPath } from "./ObjectPath";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
import { IoPlayCircleOutline } from "react-icons/io5";
|
import { IoPlayCircleOutline } from "react-icons/io5";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -62,7 +63,7 @@ export default function ObjectLifecycle({
|
|||||||
setPane,
|
setPane,
|
||||||
}: ObjectLifecycleProps) {
|
}: ObjectLifecycleProps) {
|
||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
||||||
"timeline",
|
"timeline",
|
||||||
{
|
{
|
||||||
@@ -70,7 +71,12 @@ export default function ObjectLifecycle({
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
eventSequence?.map((event) => {
|
||||||
|
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||||
|
return resolveZoneName(config, zone);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -673,7 +679,9 @@ export default function ObjectLifecycle({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-row justify-between">
|
<div className="flex w-full flex-row justify-between">
|
||||||
<div>{getLifecycleItemDescription(item)}</div>
|
<Trans>
|
||||||
|
<div>{getLifecycleItemDescription(item)}</div>
|
||||||
|
</Trans>
|
||||||
<div className={cn("p-1 text-sm")}>
|
<div className={cn("p-1 text-sm")}>
|
||||||
{formattedEventTimestamp}
|
{formattedEventTimestamp}
|
||||||
</div>
|
</div>
|
||||||
@@ -731,7 +739,9 @@ export default function ObjectLifecycle({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="smart-capitalize">
|
<span className="smart-capitalize">
|
||||||
{zone.replaceAll("_", " ")}
|
{item.data?.zones_friendly_names?.[
|
||||||
|
zidx
|
||||||
|
] ?? zone.replaceAll("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
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 { Trans, 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[];
|
||||||
@@ -42,16 +45,25 @@ 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) => {
|
||||||
x: pos.x * imgRect.width,
|
if (config && pos.lifecycle_item?.data?.zones) {
|
||||||
y: pos.y * imgRect.height,
|
pos.lifecycle_item.data.zones_friendly_names =
|
||||||
timestamp: pos.timestamp,
|
pos.lifecycle_item.data.zones.map((zone) => {
|
||||||
lifecycle_item: pos.lifecycle_item,
|
return resolveZoneName(config, zone);
|
||||||
}));
|
});
|
||||||
}, [positions, imgRef]);
|
}
|
||||||
|
return {
|
||||||
|
x: pos.x * imgRect.width,
|
||||||
|
y: pos.y * imgRect.height,
|
||||||
|
timestamp: pos.timestamp,
|
||||||
|
lifecycle_item: 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 "";
|
||||||
@@ -103,9 +115,11 @@ export function ObjectPath({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent side="top" className="smart-capitalize">
|
<TooltipContent side="top" className="smart-capitalize">
|
||||||
{pos.lifecycle_item
|
<Trans>
|
||||||
? getLifecycleItemDescription(pos.lifecycle_item)
|
{pos.lifecycle_item
|
||||||
: t("objectLifecycle.trackedPoint")}
|
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||||
|
: t("objectLifecycle.trackedPoint")}
|
||||||
|
</Trans>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { LuSearch } from "react-icons/lu";
|
|||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type ReviewDetailDialogProps = {
|
type ReviewDetailDialogProps = {
|
||||||
review?: ReviewSegment;
|
review?: ReviewSegment;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
|||||||
import FaceSelectionDialog from "../FaceSelectionDialog";
|
import FaceSelectionDialog from "../FaceSelectionDialog";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { CgTranscript } from "react-icons/cg";
|
import { CgTranscript } from "react-icons/cg";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = [
|
||||||
"details",
|
"details",
|
||||||
|
|||||||
@@ -429,7 +429,8 @@ export function ZoneFilterContent({
|
|||||||
{allZones.map((item) => (
|
{allZones.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item}
|
key={item}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item}
|
||||||
|
type={"zone"}
|
||||||
isChecked={zones?.includes(item) ?? false}
|
isChecked={zones?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ export default function PolygonItem({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="cursor-default">{polygon.name}</p>
|
<p className="cursor-default">
|
||||||
|
{polygon.friendlyName ?? polygon.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
@@ -278,7 +280,7 @@ export default function PolygonItem({
|
|||||||
ns="views/settings"
|
ns="views/settings"
|
||||||
values={{
|
values={{
|
||||||
type: polygon.type.replace("_", " "),
|
type: polygon.type.replace("_", " "),
|
||||||
name: polygon.name,
|
name: polygon.friendlyName || polygon.name,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
masksAndZones.form.polygonDrawing.delete.desc
|
masksAndZones.form.polygonDrawing.delete.desc
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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 { processZoneName } from "@/utils/zoneUtil";
|
||||||
|
|
||||||
type ZoneEditPaneProps = {
|
type ZoneEditPaneProps = {
|
||||||
polygons?: Polygon[];
|
polygons?: Polygon[];
|
||||||
@@ -146,15 +147,7 @@ export default function ZoneEditPane({
|
|||||||
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
|
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
|
|
||||||
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
|
|
||||||
})
|
|
||||||
.refine((value: string) => /[a-zA-Z]/.test(value), {
|
|
||||||
message: t(
|
|
||||||
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
inertia: z.coerce
|
inertia: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(1, {
|
.min(1, {
|
||||||
@@ -246,7 +239,11 @@ export default function ZoneEditPane({
|
|||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: polygon?.name ?? "",
|
name:
|
||||||
|
polygon?.camera && polygon?.name
|
||||||
|
? config?.cameras[polygon.camera]?.zones[polygon.name]
|
||||||
|
?.friendly_name || polygon?.name
|
||||||
|
: "",
|
||||||
inertia:
|
inertia:
|
||||||
polygon?.camera &&
|
polygon?.camera &&
|
||||||
polygon?.name &&
|
polygon?.name &&
|
||||||
@@ -305,7 +302,12 @@ export default function ZoneEditPane({
|
|||||||
let alertQueries = "";
|
let alertQueries = "";
|
||||||
let detectionQueries = "";
|
let detectionQueries = "";
|
||||||
|
|
||||||
const renamingZone = zoneName != polygon.name && polygon.name != "";
|
const renamingZone =
|
||||||
|
zoneName != polygon.name &&
|
||||||
|
zoneName != polygon.friendlyName &&
|
||||||
|
polygon.name != "";
|
||||||
|
|
||||||
|
const { finalZoneName, friendlyName } = processZoneName(zoneName);
|
||||||
|
|
||||||
if (renamingZone) {
|
if (renamingZone) {
|
||||||
// rename - delete old zone and replace with new
|
// rename - delete old zone and replace with new
|
||||||
@@ -349,7 +351,7 @@ export default function ZoneEditPane({
|
|||||||
|
|
||||||
// make sure new zone name is readded to review
|
// make sure new zone name is readded to review
|
||||||
({ alertQueries, detectionQueries } = reviewQueries(
|
({ alertQueries, detectionQueries } = reviewQueries(
|
||||||
zoneName,
|
finalZoneName,
|
||||||
zoneInAlerts,
|
zoneInAlerts,
|
||||||
zoneInDetections,
|
zoneInDetections,
|
||||||
polygon.camera,
|
polygon.camera,
|
||||||
@@ -367,7 +369,7 @@ export default function ZoneEditPane({
|
|||||||
let objectQueries = objects
|
let objectQueries = objects
|
||||||
.map(
|
.map(
|
||||||
(object) =>
|
(object) =>
|
||||||
`&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`,
|
`&cameras.${polygon?.camera}.zones.${finalZoneName}.objects=${object}`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
@@ -379,45 +381,50 @@ export default function ZoneEditPane({
|
|||||||
|
|
||||||
// deleting objects
|
// deleting objects
|
||||||
if (!objectQueries && !same_objects && !renamingZone) {
|
if (!objectQueries && !same_objects && !renamingZone) {
|
||||||
objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`;
|
objectQueries = `&cameras.${polygon?.camera}.zones.${finalZoneName}.objects`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let inertiaQuery = "";
|
let inertiaQuery = "";
|
||||||
if (inertia) {
|
if (inertia) {
|
||||||
inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`;
|
inertiaQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.inertia=${inertia}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loiteringTimeQuery = "";
|
let loiteringTimeQuery = "";
|
||||||
if (loitering_time >= 0) {
|
if (loitering_time >= 0) {
|
||||||
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
|
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.loitering_time=${loitering_time}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let distancesQuery = "";
|
let distancesQuery = "";
|
||||||
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
||||||
if (speedEstimation) {
|
if (speedEstimation) {
|
||||||
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
|
distancesQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.distances=${distances}`;
|
||||||
} else {
|
} else {
|
||||||
if (distances != "") {
|
if (distances != "") {
|
||||||
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
|
distancesQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.distances`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let speedThresholdQuery = "";
|
let speedThresholdQuery = "";
|
||||||
if (speed_threshold >= 0 && speedEstimation) {
|
if (speed_threshold >= 0 && speedEstimation) {
|
||||||
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`;
|
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.speed_threshold=${speed_threshold}`;
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (
|
||||||
polygon?.camera &&
|
polygon?.camera &&
|
||||||
polygon?.name &&
|
polygon?.name &&
|
||||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
|
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
|
||||||
) {
|
) {
|
||||||
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`;
|
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.speed_threshold`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let friendlyNameQuery = "";
|
||||||
|
if (friendlyName) {
|
||||||
|
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.friendly_name=${encodeURIComponent(friendlyName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.put(
|
.put(
|
||||||
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
|
`config/set?cameras.${polygon?.camera}.zones.${finalZoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||||
{
|
{
|
||||||
requires_restart: 0,
|
requires_restart: 0,
|
||||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||||
@@ -427,7 +434,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,
|
zoneName: friendlyName,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
formatUnixTimestampToDateTime,
|
formatUnixTimestampToDateTime,
|
||||||
formatSecondsToDuration,
|
formatSecondsToDuration,
|
||||||
} from "@/utils/dateUtil";
|
} from "@/utils/dateUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@@ -27,6 +27,7 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
|||||||
import EventMenu from "@/components/timeline/EventMenu";
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
|
||||||
type DetailStreamProps = {
|
type DetailStreamProps = {
|
||||||
reviewItems?: ReviewSegment[];
|
reviewItems?: ReviewSegment[];
|
||||||
@@ -503,6 +504,10 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
|||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
event.data.zones_friendly_names = event?.data?.zones?.map((zone) => {
|
||||||
|
return resolveZoneName(config, zone);
|
||||||
|
});
|
||||||
|
|
||||||
const formattedEventTimestamp = config
|
const formattedEventTimestamp = config
|
||||||
? formatUnixTimestampToDateTime(event.timestamp ?? 0, {
|
? formatUnixTimestampToDateTime(event.timestamp ?? 0, {
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
@@ -536,7 +541,9 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
|||||||
<LifecycleIcon lifecycleItem={event} className="size-3" />
|
<LifecycleIcon lifecycleItem={event} className="size-3" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-row justify-between">
|
<div className="flex w-full flex-row justify-between">
|
||||||
<div>{getLifecycleItemDescription(event)}</div>
|
<Trans>
|
||||||
|
<div>{getLifecycleItemDescription(event)}</div>
|
||||||
|
</Trans>
|
||||||
<div className={cn("p-1 text-xs")}>{formattedEventTimestamp}</div>
|
<div className={cn("p-1 text-xs")}>{formattedEventTimestamp}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -642,7 +642,7 @@ function CameraSelectButton({
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
isChecked={item.name === selectedCamera}
|
isChecked={item.name === selectedCamera}
|
||||||
label={item.name}
|
label={item.name}
|
||||||
isCameraName={true}
|
type={"camera"}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
||||||
setSelectedCamera(item.name);
|
setSelectedCamera(item.name);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type Polygon = {
|
|||||||
typeIndex: number;
|
typeIndex: number;
|
||||||
camera: string;
|
camera: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
friendlyName?: string;
|
||||||
type: PolygonType;
|
type: PolygonType;
|
||||||
objects: string[];
|
objects: string[];
|
||||||
points: number[][];
|
points: number[][];
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ export interface CameraConfig {
|
|||||||
webui_url: string | null;
|
webui_url: string | null;
|
||||||
zones: {
|
zones: {
|
||||||
[zoneName: string]: {
|
[zoneName: string]: {
|
||||||
|
friendly_name?: string;
|
||||||
coordinates: string;
|
coordinates: string;
|
||||||
distances: string[];
|
distances: string[];
|
||||||
filters: Record<string, unknown>;
|
filters: Record<string, unknown>;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type ObjectLifecycleSequence = {
|
|||||||
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;
|
||||||
|
|||||||
@@ -1,29 +1,7 @@
|
|||||||
|
import { generateFixedHash } from "./stringUtil";
|
||||||
|
|
||||||
// ==================== Camera Name Processing ====================
|
// ==================== Camera Name Processing ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
|
||||||
* Works safely with Unicode input while outputting Latin-only identifiers.
|
|
||||||
*
|
|
||||||
* @param name - The original camera name/display name
|
|
||||||
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
|
||||||
*/
|
|
||||||
export function generateFixedHash(name: string): string {
|
|
||||||
// Safely encode Unicode as UTF-8 bytes
|
|
||||||
const utf8Bytes = new TextEncoder().encode(name);
|
|
||||||
|
|
||||||
// Convert to base64 manually
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of utf8Bytes) {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
}
|
|
||||||
const base64 = btoa(binary);
|
|
||||||
|
|
||||||
// Strip out non-alphanumeric characters and truncate
|
|
||||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
|
||||||
|
|
||||||
return `cam_${cleanHash.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a string is a valid camera name identifier.
|
* Checks if a string is a valid camera name identifier.
|
||||||
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
|
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { getTranslatedLabel } from "./i18n";
|
import i18n, { getTranslatedLabel } from "./i18n";
|
||||||
|
|
||||||
export function getLifecycleItemDescription(
|
export function getLifecycleItemDescription(
|
||||||
lifecycleItem: ObjectLifecycleSequence,
|
lifecycleItem: ObjectLifecycleSequence,
|
||||||
@@ -11,6 +11,17 @@ export function getLifecycleItemDescription(
|
|||||||
|
|
||||||
const label = getTranslatedLabel(rawLabel);
|
const label = getTranslatedLabel(rawLabel);
|
||||||
|
|
||||||
|
let supportedListFormat = false;
|
||||||
|
let zonesFormatter: Intl.ListFormat | null = null;
|
||||||
|
|
||||||
|
if (typeof Intl !== "undefined" && Intl.ListFormat) {
|
||||||
|
supportedListFormat = true;
|
||||||
|
zonesFormatter = new Intl.ListFormat(i18n.language, {
|
||||||
|
style: "long",
|
||||||
|
type: "conjunction",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
switch (lifecycleItem.class_type) {
|
switch (lifecycleItem.class_type) {
|
||||||
case "visible":
|
case "visible":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.visible", {
|
return t("objectLifecycle.lifecycleItemDesc.visible", {
|
||||||
@@ -21,7 +32,18 @@ export function getLifecycleItemDescription(
|
|||||||
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
|
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
|
zones:
|
||||||
|
supportedListFormat && zonesFormatter
|
||||||
|
? zonesFormatter.format(
|
||||||
|
lifecycleItem.data.zones_friendly_names?.map(
|
||||||
|
(x) => `<strong>${x}</strong>`,
|
||||||
|
) ??
|
||||||
|
lifecycleItem.data.zones.map((x) => `<strong>${x}</strong>`),
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
lifecycleItem.data.zones_friendly_names ??
|
||||||
|
lifecycleItem.data.zones
|
||||||
|
).join(" and "),
|
||||||
});
|
});
|
||||||
case "active":
|
case "active":
|
||||||
return t("objectLifecycle.lifecycleItemDesc.active", {
|
return t("objectLifecycle.lifecycleItemDesc.active", {
|
||||||
|
|||||||
@@ -9,3 +9,31 @@ export const capitalizeAll = (text: string): string => {
|
|||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
||||||
|
* Works safely with Unicode input while outputting Latin-only identifiers.
|
||||||
|
*
|
||||||
|
* @param name - The original camera/zones name/display name
|
||||||
|
* @param prefix - The prefix to use for the generated camera/zones name (default: "cam_")
|
||||||
|
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
||||||
|
*/
|
||||||
|
export function generateFixedHash(
|
||||||
|
name: string,
|
||||||
|
prefix: string = "cam_",
|
||||||
|
): string {
|
||||||
|
// Safely encode Unicode as UTF-8 bytes
|
||||||
|
const utf8Bytes = new TextEncoder().encode(name);
|
||||||
|
|
||||||
|
// Convert to base64 manually
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of utf8Bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
|
||||||
|
// Strip out non-alphanumeric characters and truncate
|
||||||
|
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
||||||
|
|
||||||
|
return `${prefix}_${cleanHash.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { isValidCameraName } from "./cameraUtil.ts";
|
||||||
|
import { generateFixedHash } from "./stringUtil.ts";
|
||||||
|
|
||||||
|
export function processZoneName(userInput: string): {
|
||||||
|
finalZoneName: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
} {
|
||||||
|
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
|
||||||
|
|
||||||
|
if (isValidCameraName(normalizedInput)) {
|
||||||
|
return {
|
||||||
|
finalZoneName: normalizedInput,
|
||||||
|
friendlyName: userInput.includes(" ") ? userInput : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalZoneName: generateFixedHash(userInput, "zone"),
|
||||||
|
friendlyName: userInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
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";
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type AuthenticationViewProps = {
|
type AuthenticationViewProps = {
|
||||||
section?: "users" | "roles";
|
section?: "users" | "roles";
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
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";
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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";
|
||||||
@@ -42,6 +41,7 @@ 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";
|
||||||
|
|
||||||
type CameraSettingsViewProps = {
|
type CameraSettingsViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@@ -86,16 +86,23 @@ 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]);
|
}, [cameraConfig, getZoneName]);
|
||||||
|
|
||||||
const alertsLabels = useMemo(() => {
|
const alertsLabels = useMemo(() => {
|
||||||
return cameraConfig?.review.alerts.labels
|
return cameraConfig?.review.alerts.labels
|
||||||
@@ -526,7 +533,7 @@ export default function CameraSettingsView({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="font-normal smart-capitalize">
|
<FormLabel className="font-normal smart-capitalize">
|
||||||
{zone.name.replaceAll("_", " ")}
|
{zone.friendly_name}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -549,12 +556,7 @@ export default function CameraSettingsView({
|
|||||||
{
|
{
|
||||||
alertsLabels,
|
alertsLabels,
|
||||||
zone: watchedAlertsZones
|
zone: watchedAlertsZones
|
||||||
.map((zone) =>
|
.map((zone) => getZoneName(zone))
|
||||||
capitalizeFirstLetter(zone).replaceAll(
|
|
||||||
"_",
|
|
||||||
" ",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.join(", "),
|
.join(", "),
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
},
|
},
|
||||||
@@ -628,7 +630,7 @@ export default function CameraSettingsView({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="font-normal smart-capitalize">
|
<FormLabel className="font-normal smart-capitalize">
|
||||||
{zone.name.replaceAll("_", " ")}
|
{zone.friendly_name}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -668,12 +670,7 @@ export default function CameraSettingsView({
|
|||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
zone: watchedDetectionsZones
|
zone: watchedDetectionsZones
|
||||||
.map((zone) =>
|
.map((zone) => getZoneName(zone))
|
||||||
capitalizeFirstLetter(zone).replaceAll(
|
|
||||||
"_",
|
|
||||||
" ",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.join(", "),
|
.join(", "),
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
}}
|
}}
|
||||||
@@ -685,12 +682,7 @@ export default function CameraSettingsView({
|
|||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
zone: watchedDetectionsZones
|
zone: watchedDetectionsZones
|
||||||
.map((zone) =>
|
.map((zone) => getZoneName(zone))
|
||||||
capitalizeFirstLetter(zone).replaceAll(
|
|
||||||
"_",
|
|
||||||
" ",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.join(", "),
|
.join(", "),
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type FrigatePlusModel = {
|
type FrigatePlusModel = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export default function MasksAndZonesView({
|
|||||||
typeIndex: index,
|
typeIndex: index,
|
||||||
camera: cameraConfig.name,
|
camera: cameraConfig.name,
|
||||||
name,
|
name,
|
||||||
|
friendlyName: zoneData.friendly_name,
|
||||||
objects: zoneData.objects,
|
objects: zoneData.objects,
|
||||||
points: interpolatePoints(
|
points: interpolatePoints(
|
||||||
parseCoordinates(zoneData.coordinates),
|
parseCoordinates(zoneData.coordinates),
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
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}
|
||||||
isCameraName={true}
|
type={"camera"}
|
||||||
isChecked={field.value?.includes(
|
isChecked={field.value?.includes(
|
||||||
camera.name,
|
camera.name,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
|
||||||
type CameraMetricsProps = {
|
type CameraMetricsProps = {
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2021",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.String"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable", "ES2021.String"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
|||||||
Reference in New Issue
Block a user