diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh index c63c015d3..a61a8a9fb 100755 --- a/docker/main/install_deps.sh +++ b/docker/main/install_deps.sh @@ -1,3 +1,4 @@ + #!/bin/bash set -euxo pipefail diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx index cd1b27493..37e54a49c 100644 --- a/web/src/components/mobile/MobilePage.tsx +++ b/web/src/components/mobile/MobilePage.tsx @@ -1,29 +1,101 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { IoMdArrowRoundBack } from "react-icons/io"; import { cn } from "@/lib/utils"; import { isPWA } from "@/utils/isPWA"; -import { ReactNode, useEffect, useState } from "react"; -import { Button } from "../ui/button"; -import { IoMdArrowRoundBack } from "react-icons/io"; -import { motion, AnimatePresence } from "framer-motion"; +import { Button } from "@/components/ui/button"; -type MobilePageProps = { - children: ReactNode; +const MobilePageContext = createContext<{ open: boolean; onOpenChange: (open: boolean) => void; +} | null>(null); + +type MobilePageProps = { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }; -export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { - const [isVisible, setIsVisible] = useState(open); +export function MobilePage({ + children, + open: controlledOpen, + onOpenChange, +}: MobilePageProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; + + return ( + + {children} + + ); +} + +type MobilePageTriggerProps = React.HTMLAttributes; + +export function MobilePageTrigger({ + children, + ...props +}: MobilePageTriggerProps) { + const context = useContext(MobilePageContext); + if (!context) + throw new Error("MobilePageTrigger must be used within MobilePage"); + + return ( +
context.onOpenChange(true)} {...props}> + {children} +
+ ); +} + +type MobilePagePortalProps = { + children: React.ReactNode; + container?: HTMLElement; +}; + +export function MobilePagePortal({ + children, + container, +}: MobilePagePortalProps) { + const [mounted, setMounted] = useState(false); useEffect(() => { - if (open) { + setMounted(true); + return () => setMounted(false); + }, []); + + if (!mounted) return null; + + return createPortal(children, container || document.body); +} + +type MobilePageContentProps = { + children: React.ReactNode; + className?: string; +}; + +export function MobilePageContent({ + children, + className, +}: MobilePageContentProps) { + const context = useContext(MobilePageContext); + if (!context) + throw new Error("MobilePageContent must be used within MobilePage"); + + const [isVisible, setIsVisible] = useState(context.open); + + useEffect(() => { + if (context.open) { setIsVisible(true); } - }, [open]); + }, [context.open]); const handleAnimationComplete = () => { - if (!open) { + if (!context.open) { setIsVisible(false); - onOpenChange(false); } }; @@ -35,9 +107,10 @@ export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { "fixed inset-0 z-50 mb-12 bg-background", isPWA && "mb-16", "landscape:mb-14 landscape:md:mb-16", + className, )} initial={{ x: "100%" }} - animate={{ x: open ? 0 : "100%" }} + animate={{ x: context.open ? 0 : "100%" }} exit={{ x: "100%" }} transition={{ type: "spring", damping: 25, stiffness: 200 }} onAnimationComplete={handleAnimationComplete} @@ -49,37 +122,8 @@ export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { ); } -type MobileComponentProps = { - children: ReactNode; - className?: string; -}; - -export function MobilePageContent({ - children, - className, - ...props -}: MobileComponentProps) { - return ( -
- {children} -
- ); -} - -export function MobilePageDescription({ - children, - className, - ...props -}: MobileComponentProps) { - return ( -

- {children} -

- ); -} - interface MobilePageHeaderProps extends React.HTMLAttributes { - onClose: () => void; + onClose?: () => void; } export function MobilePageHeader({ @@ -88,6 +132,18 @@ export function MobilePageHeader({ onClose, ...props }: MobilePageHeaderProps) { + const context = useContext(MobilePageContext); + if (!context) + throw new Error("MobilePageHeader must be used within MobilePage"); + + const handleClose = () => { + if (onClose) { + onClose(); + } else { + context.onOpenChange(false); + } + }; + return (
@@ -108,14 +164,19 @@ export function MobilePageHeader({ ); } -export function MobilePageTitle({ - children, +type MobilePageTitleProps = React.HTMLAttributes; + +export function MobilePageTitle({ className, ...props }: MobilePageTitleProps) { + return

; +} + +type MobilePageDescriptionProps = React.HTMLAttributes; + +export function MobilePageDescription({ className, ...props -}: MobileComponentProps) { +}: MobilePageDescriptionProps) { return ( -

- {children} -

+

); } diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 47af3e309..37813645b 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -150,7 +150,14 @@ export default function SearchDetailDialog({ const Description = isDesktop ? DialogDescription : MobilePageDescription; return ( - setIsOpen(!isOpen)}> + { + if (search) { + setSearch(undefined); + } + }} + > -

onOpenChange(true)}>{trigger}
- + + onOpenChange(true)}> + {trigger} + +
{content}
-
-

+ + ); } diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2bfc5afac..cb8ed50ad 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -20,7 +20,7 @@ import { FormMessage, } from "@/components/ui/form"; import { useCallback, useEffect, useMemo } from "react"; -import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; +import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -37,6 +37,7 @@ import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; import ActivityIndicator from "../indicators/activity-indicator"; +import { getAttributeLabels } from "@/utils/iconUtil"; type ObjectMaskEditPaneProps = { polygons?: Polygon[]; @@ -367,6 +368,14 @@ type ZoneObjectSelectorProps = { export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) { const { data: config } = useSWR("config"); + const attributeLabels = useMemo(() => { + if (!config) { + return []; + } + + return getAttributeLabels(config); + }, [config]); + const cameraConfig = useMemo(() => { if (config && camera) { return config.cameras[camera]; @@ -382,20 +391,20 @@ export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) { Object.values(config.cameras).forEach((camera) => { camera.objects.track.forEach((label) => { - if (!ATTRIBUTE_LABELS.includes(label)) { + if (!attributeLabels.includes(label)) { labels.add(label); } }); }); cameraConfig.objects.track.forEach((label) => { - if (!ATTRIBUTE_LABELS.includes(label)) { + if (!attributeLabels.includes(label)) { labels.add(label); } }); return [...labels].sort(); - }, [config, cameraConfig]); + }, [config, cameraConfig, attributeLabels]); return ( <> diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 7600c3b29..949cfd1ac 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -12,7 +12,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; +import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -28,6 +28,7 @@ import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; import ActivityIndicator from "../indicators/activity-indicator"; +import { getAttributeLabels } from "@/utils/iconUtil"; type ZoneEditPaneProps = { polygons?: Polygon[]; @@ -505,6 +506,14 @@ export function ZoneObjectSelector({ }: ZoneObjectSelectorProps) { const { data: config } = useSWR("config"); + const attributeLabels = useMemo(() => { + if (!config) { + return []; + } + + return getAttributeLabels(config); + }, [config]); + const cameraConfig = useMemo(() => { if (config && camera) { return config.cameras[camera]; @@ -519,7 +528,7 @@ export function ZoneObjectSelector({ const labels = new Set(); cameraConfig.objects.track.forEach((label) => { - if (!ATTRIBUTE_LABELS.includes(label)) { + if (!attributeLabels.includes(label)) { labels.add(label); } }); @@ -527,7 +536,7 @@ export function ZoneObjectSelector({ if (zoneName) { if (cameraConfig.zones[zoneName]) { cameraConfig.zones[zoneName].objects.forEach((label) => { - if (!ATTRIBUTE_LABELS.includes(label)) { + if (!attributeLabels.includes(label)) { labels.add(label); } }); @@ -535,7 +544,7 @@ export function ZoneObjectSelector({ } return [...labels].sort() || []; - }, [config, cameraConfig, zoneName]); + }, [config, cameraConfig, attributeLabels, zoneName]); const [currentLabels, setCurrentLabels] = useState( selectedLabels, diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 7b6128ace..bbf70ba32 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -3,7 +3,7 @@ import { useInitialCameraState, useMotionActivity, } from "@/api/ws"; -import { ATTRIBUTE_LABELS, CameraConfig } from "@/types/frigateConfig"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { MotionData, ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTimelineUtils } from "./use-timeline-utils"; @@ -11,6 +11,8 @@ import { ObjectType } from "@/types/ws"; import useDeepMemo from "./use-deep-memo"; import { isEqual } from "lodash"; import { useAutoFrigateStats } from "./use-stats"; +import useSWR from "swr"; +import { getAttributeLabels } from "@/utils/iconUtil"; type useCameraActivityReturn = { activeTracking: boolean; @@ -23,6 +25,16 @@ export function useCameraActivity( camera: CameraConfig, revalidateOnFocus: boolean = true, ): useCameraActivityReturn { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const attributeLabels = useMemo(() => { + if (!config) { + return []; + } + + return getAttributeLabels(config); + }, [config]); const [objects, setObjects] = useState([]); // init camera activity @@ -99,7 +111,7 @@ export function useCameraActivity( if (updatedEvent.after.sub_label) { const sub_label = updatedEvent.after.sub_label[0]; - if (ATTRIBUTE_LABELS.includes(sub_label)) { + if (attributeLabels.includes(sub_label)) { label = sub_label; } else { label = `${label}-verified`; @@ -113,7 +125,7 @@ export function useCameraActivity( } handleSetObjects(newObjects); - }, [camera, updatedEvent, objects, handleSetObjects]); + }, [attributeLabels, camera, updatedEvent, objects, handleSetObjects]); // determine if camera is offline diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 76d9cfa67..1d54b4e7e 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -19,14 +19,6 @@ export interface BirdseyeConfig { width: number; } -export const ATTRIBUTE_LABELS = [ - "amazon", - "face", - "fedex", - "license_plate", - "ups", -]; - export type SearchModelSize = "small" | "large"; export interface CameraConfig { diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index e3c1f5508..38cea4159 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -1,4 +1,5 @@ import { IconName } from "@/components/icons/IconPicker"; +import { FrigateConfig } from "@/types/frigateConfig"; import { BsPersonWalking } from "react-icons/bs"; import { FaAmazon, @@ -36,6 +37,19 @@ import { LuBox, LuLassoSelect } from "react-icons/lu"; import * as LuIcons from "react-icons/lu"; import { MdRecordVoiceOver } from "react-icons/md"; +export function getAttributeLabels(config?: FrigateConfig) { + if (!config) { + return []; + } + + const labels = new Set(); + + Object.values(config.model.attributes_map).forEach((values) => + values.forEach((label) => labels.add(label)), + ); + return [...labels]; +} + export function isValidIconName(value: string): value is IconName { return Object.keys(LuIcons).includes(value as IconName); }