Merge branch 'dev' into fastapi-poc

This commit is contained in:
Rui Alves 2024-09-13 08:51:53 +01:00
commit b87fc3304c
33 changed files with 700 additions and 253 deletions

26
web/package-lock.json generated
View File

@ -37,6 +37,7 @@
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0", "embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.14", "hls.js": "^1.5.14",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
@ -4717,6 +4718,31 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "11.5.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.5.4.tgz",
"integrity": "sha512-E+tb3/G6SO69POkdJT+3EpdMuhmtCh9EWuK4I1DnIC23L7tFPrl8vxP+LSovwaw6uUr73rUbpb4FgK011wbRJQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",

View File

@ -43,6 +43,7 @@
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0", "embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.14", "hls.js": "^1.5.14",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",

View File

@ -93,6 +93,7 @@ export default function CameraImage({
"rounded-lg md:rounded-2xl", "rounded-lg md:rounded-2xl",
)} )}
onLoad={handleImageLoad} onLoad={handleImageLoad}
loading="lazy"
/> />
) : ( ) : (
<div className="pt-6 text-center"> <div className="pt-6 text-center">

View File

@ -91,13 +91,17 @@ export function AnimatedEventCard({
const [alertVideos] = usePersistence("alertVideos", true); const [alertVideos] = usePersistence("alertVideos", true);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!config || !Object.keys(config.cameras).includes(event.camera)) { if (
!config ||
!alertVideos ||
!Object.keys(config.cameras).includes(event.camera)
) {
return 16 / 9; return 16 / 9;
} }
const detect = config.cameras[event.camera].detect; const detect = config.cameras[event.camera].detect;
return detect.width / detect.height; return detect.width / detect.height;
}, [config, event]); }, [alertVideos, config, event]);
return ( return (
<Tooltip> <Tooltip>

View File

@ -2,8 +2,8 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { LuTrash } from "react-icons/lu"; import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { FaDownload, FaPlay } from "react-icons/fa"; import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
import Chip from "../indicators/Chip"; import Chip from "../indicators/Chip";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { import {
@ -19,6 +19,7 @@ import { DeleteClipType, Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md"; import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
type ExportProps = { type ExportProps = {
className: string; className: string;
@ -81,7 +82,13 @@ export default function ExportCard({
} }
}} }}
> >
<DialogContent> <DialogContent
onOpenAutoFocus={(e) => {
if (isMobile) {
e.preventDefault();
}
}}
>
<DialogTitle>Rename Export</DialogTitle> <DialogTitle>Rename Export</DialogTitle>
<DialogDescription> <DialogDescription>
Enter a new name for this export. Enter a new name for this export.
@ -89,7 +96,7 @@ export default function ExportCard({
{editName && ( {editName && (
<> <>
<Input <Input
className="mt-3" className="text-md mt-3"
type="search" type="search"
placeholder={editName?.original} placeholder={editName?.original}
value={ value={
@ -147,6 +154,19 @@ export default function ExportCard({
<div> <div>
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" /> <div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
<div className="absolute right-1 top-1 flex items-center gap-2"> <div className="absolute right-1 top-1 flex items-center gap-2">
{!exportedRecording.in_progress && (
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() =>
shareOrCopy(
`${baseUrl}exports?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
)
}
>
<FaShareAlt className="size-4 text-white" />
</Chip>
)}
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<a <a
download download

View File

@ -50,6 +50,7 @@ export default function ReviewCard({
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
event.start_time, event.start_time,
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
config?.ui.timezone,
); );
const isSelected = useMemo( const isSelected = useMemo(
() => () =>

View File

@ -42,6 +42,7 @@ export default function SearchThumbnail({
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
searchResult.start_time, searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
config?.ui.timezone,
); );
return ( return (

View File

@ -7,8 +7,13 @@ import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu"; import { LuPencil, LuPlus } from "react-icons/lu";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import {
import { Drawer, DrawerContent } from "../ui/drawer"; Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { import {
@ -24,6 +29,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { import {
@ -53,6 +59,13 @@ import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu"; import * as LuIcons from "react-icons/lu";
import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker"; import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker";
import { isValidIconName } from "@/utils/iconUtil"; import { isValidIconName } from "@/utils/iconUtil";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "../mobile/MobilePage";
type CameraGroupSelectorProps = { type CameraGroupSelectorProps = {
className?: string; className?: string;
@ -278,6 +291,7 @@ function NewGroupDialog({
const onSave = () => { const onSave = () => {
setOpen(false); setOpen(false);
setEditState("none"); setEditState("none");
setEditingGroupName("");
}; };
const onCancel = () => { const onCancel = () => {
@ -290,8 +304,11 @@ function NewGroupDialog({
setEditState("edit"); setEditState("edit");
}, []); }, []);
const Overlay = isDesktop ? Dialog : Drawer; const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : DrawerContent; const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Description = isDesktop ? DialogDescription : MobilePageDescription;
const Title = isDesktop ? DialogTitle : MobilePageTitle;
return ( return (
<> <>
@ -308,16 +325,36 @@ function NewGroupDialog({
}} }}
> >
<Content <Content
className={`min-w-0 ${isMobile ? "max-h-[90%] w-full rounded-t-2xl p-3" : "max-h-dvh w-6/12 overflow-y-hidden"}`} className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && "my-4 flex max-h-dvh w-6/12 flex-col",
isMobile && "px-4",
)}
> >
<div className="scrollbar-container my-4 flex flex-col overflow-y-auto"> {editState === "none" && (
{editState === "none" && ( <>
<> <Header
<div className="flex flex-row items-center justify-between py-2"> className={cn(isDesktop && "mt-5", "justify-center")}
<DialogTitle>Camera Groups</DialogTitle> onClose={() => setOpen(false)}
>
<Title>Camera Groups</Title>
<Description className="sr-only">
Edit camera groups
</Description>
<div
className={cn(
"absolute",
isDesktop && "right-6 top-10",
isMobile && "absolute right-0 top-4",
)}
>
<Button <Button
variant="secondary" size="sm"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background" className={cn(
isDesktop &&
"size-6 rounded-md bg-secondary-foreground p-1 text-background",
isMobile && "text-secondary-foreground",
)}
onClick={() => { onClick={() => {
setEditState("add"); setEditState("add");
}} }}
@ -325,6 +362,8 @@ function NewGroupDialog({
<LuPlus /> <LuPlus />
</Button> </Button>
</div> </div>
</Header>
<div className="flex flex-col gap-4 md:gap-3">
{currentGroups.map((group) => ( {currentGroups.map((group) => (
<CameraGroupRow <CameraGroupRow
key={group[0]} key={group[0]}
@ -333,27 +372,36 @@ function NewGroupDialog({
onEditGroup={() => onEditGroup(group)} onEditGroup={() => onEditGroup(group)}
/> />
))} ))}
</> </div>
)} </>
)}
{editState != "none" && ( {editState != "none" && (
<> <>
<div className="mb-3 flex flex-row items-center justify-between"> <Header
<DialogTitle> className="mt-2"
{editState == "add" ? "Add" : "Edit"} Camera Group onClose={() => {
</DialogTitle> setEditState("none");
</div> setEditingGroupName("");
<CameraGroupEdit }}
currentGroups={currentGroups} >
editingGroup={editingGroup} <Title>
isLoading={isLoading} {editState == "add" ? "Add" : "Edit"} Camera Group
setIsLoading={setIsLoading} </Title>
onSave={onSave} <Description className="sr-only">
onCancel={onCancel} Edit camera groups
/> </Description>
</> </Header>
)} <CameraGroupEdit
</div> currentGroups={currentGroups}
editingGroup={editingGroup}
isLoading={isLoading}
setIsLoading={setIsLoading}
onSave={onSave}
onCancel={onCancel}
/>
</>
)}
</Content> </Content>
</Overlay> </Overlay>
</> </>
@ -372,6 +420,12 @@ export function EditGroupDialog({
currentGroups, currentGroups,
activeGroup, activeGroup,
}: EditGroupDialogProps) { }: EditGroupDialogProps) {
const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Description = isDesktop ? DialogDescription : MobilePageDescription;
const Title = isDesktop ? DialogTitle : MobilePageTitle;
// editing group and state // editing group and state
const editingGroup = useMemo(() => { const editingGroup = useMemo(() => {
@ -391,19 +445,24 @@ export function EditGroupDialog({
position="top-center" position="top-center"
closeButton={true} closeButton={true}
/> />
<Dialog <Overlay
open={open} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
setOpen(open); setOpen(open);
}} }}
> >
<DialogContent <Content
className={`min-w-0 ${isMobile ? "max-h-[90%] w-full rounded-t-2xl p-3" : "max-h-dvh w-6/12 overflow-y-hidden"}`} className={cn(
"min-w-0",
isDesktop && "max-h-dvh w-6/12 overflow-y-hidden",
)}
> >
<div className="scrollbar-container my-4 flex flex-col overflow-y-auto"> <div className="scrollbar-container flex flex-col overflow-y-auto md:my-4">
<div className="mb-3 flex flex-row items-center justify-between"> <Header className="mt-2" onClose={() => setOpen(false)}>
<DialogTitle>Edit Camera Group</DialogTitle> <Title>Edit Camera Group</Title>
</div> <Description className="sr-only">Edit camera group</Description>
</Header>
<CameraGroupEdit <CameraGroupEdit
currentGroups={currentGroups} currentGroups={currentGroups}
editingGroup={editingGroup} editingGroup={editingGroup}
@ -413,8 +472,8 @@ export function EditGroupDialog({
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
/> />
</div> </div>
</DialogContent> </Content>
</Dialog> </Overlay>
</> </>
); );
} }
@ -440,7 +499,7 @@ export function CameraGroupRow({
<> <>
<div <div
key={group[0]} key={group[0]}
className="transition-background my-1.5 flex flex-row items-center justify-between rounded-lg duration-100 md:p-1" className="transition-background flex flex-row items-center justify-between rounded-lg duration-100 md:p-1"
> >
<div className={`flex items-center`}> <div className={`flex items-center`}>
<p className="cursor-default">{group[0]}</p> <p className="cursor-default">{group[0]}</p>
@ -472,12 +531,16 @@ export function CameraGroupRow({
<DropdownMenuTrigger> <DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" /> <HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuPortal>
<DropdownMenuItem onClick={onEditGroup}>Edit</DropdownMenuItem> <DropdownMenuContent>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}> <DropdownMenuItem onClick={onEditGroup}>
Delete Edit
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu> </DropdownMenu>
</> </>
)} )}
@ -647,7 +710,7 @@ export function CameraGroupEdit({
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter a name..." placeholder="Enter a name..."
disabled={editingGroup !== undefined} disabled={editingGroup !== undefined}
{...field} {...field}
@ -659,7 +722,7 @@ export function CameraGroupEdit({
/> />
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<div className="scrollbar-container max-h-[25dvh] overflow-y-auto md:max-h-[40dvh]"> <div className="scrollbar-container max-h-[40dvh] overflow-y-auto">
<FormField <FormField
control={form.control} control={form.control}
name="cameras" name="cameras"

View File

@ -470,6 +470,7 @@ export function GeneralFilterContent({
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{allLabels.map((item) => ( {allLabels.map((item) => (
<FilterSwitch <FilterSwitch
key={item}
label={item.replaceAll("_", " ")} label={item.replaceAll("_", " ")}
isChecked={currentLabels?.includes(item) ?? false} isChecked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
@ -516,6 +517,7 @@ export function GeneralFilterContent({
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item}
label={item.replaceAll("_", " ")} label={item.replaceAll("_", " ")}
isChecked={currentZones?.includes(item) ?? false} isChecked={currentZones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {

View File

@ -0,0 +1,121 @@
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";
type MobilePageProps = {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function MobilePage({ children, open, onOpenChange }: MobilePageProps) {
const [isVisible, setIsVisible] = useState(open);
useEffect(() => {
if (open) {
setIsVisible(true);
}
}, [open]);
const handleAnimationComplete = () => {
if (!open) {
setIsVisible(false);
onOpenChange(false);
}
};
return (
<AnimatePresence>
{isVisible && (
<motion.div
className={cn(
"fixed inset-0 z-50 mb-12 bg-background",
isPWA && "mb-16",
"landscape:mb-14 landscape:md:mb-16",
)}
initial={{ x: "100%" }}
animate={{ x: open ? 0 : "100%" }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
onAnimationComplete={handleAnimationComplete}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
type MobileComponentProps = {
children: ReactNode;
className?: string;
};
export function MobilePageContent({
children,
className,
...props
}: MobileComponentProps) {
return (
<div className={cn("size-full", className)} {...props}>
{children}
</div>
);
}
export function MobilePageDescription({
children,
className,
...props
}: MobileComponentProps) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props}>
{children}
</p>
);
}
interface MobilePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
onClose: () => void;
}
export function MobilePageHeader({
children,
className,
onClose,
...props
}: MobilePageHeaderProps) {
return (
<div
className={cn(
"sticky top-0 z-50 mb-2 flex items-center justify-center bg-background p-4",
className,
)}
{...props}
>
<Button
className="absolute left-0 rounded-lg"
size="sm"
onClick={onClose}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
</Button>
<div className="flex flex-row text-center">{children}</div>
</div>
);
}
export function MobilePageTitle({
children,
className,
...props
}: MobileComponentProps) {
return (
<h2 className={cn("text-lg font-semibold", className)} {...props}>
{children}
</h2>
);
}

View File

@ -74,7 +74,7 @@ export default function CreateUserDialog({
<FormLabel>User</FormLabel> <FormLabel>User</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -89,7 +89,7 @@ export default function CreateUserDialog({
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password" type="password"
{...field} {...field}
/> />

View File

@ -273,7 +273,7 @@ export function ExportContent({
/> />
)} )}
<Input <Input
className="my-6" className="text-md my-6"
type="search" type="search"
placeholder="Name the Export" placeholder="Name the Export"
value={name} value={name}
@ -431,7 +431,7 @@ function CustomTimeSelector({
/> />
<SelectSeparator className="bg-secondary" /> <SelectSeparator className="bg-secondary" />
<input <input
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime" id="startTime"
type="time" type="time"
value={startClock} value={startClock}
@ -493,7 +493,7 @@ function CustomTimeSelector({
/> />
<SelectSeparator className="bg-secondary" /> <SelectSeparator className="bg-secondary" />
<input <input
className="mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime" id="startTime"
type="time" type="time"
value={endClock} value={endClock}

View File

@ -23,12 +23,12 @@ export default function SetPasswordDialog({
return ( return (
<Dialog open={show} onOpenChange={onCancel}> <Dialog open={show} onOpenChange={onCancel}>
<DialogContent> <DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Set Password</DialogTitle> <DialogTitle>Set Password</DialogTitle>
</DialogHeader> </DialogHeader>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}

View File

@ -178,7 +178,7 @@ export function AnnotationSettingsPane({
<div className="flex flex-col"> <div className="flex flex-col">
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0" placeholder="0"
{...field} {...field}
/> />

View File

@ -13,7 +13,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ObjectLifecycleSequence } from "@/types/timeline"; import { ObjectLifecycleSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
@ -47,14 +47,16 @@ import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
type ObjectLifecycleProps = { type ObjectLifecycleProps = {
review: ReviewSegment; className?: string;
event: Event; event: Event;
fullscreen?: boolean;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>; setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
}; };
export default function ObjectLifecycle({ export default function ObjectLifecycle({
review, className,
event, event,
fullscreen = false,
setPane, setPane,
}: ObjectLifecycleProps) { }: ObjectLifecycleProps) {
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([ const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
@ -78,13 +80,13 @@ export default function ObjectLifecycle({
const getZoneColor = useCallback( const getZoneColor = useCallback(
(zoneName: string) => { (zoneName: string) => {
const zoneColor = const zoneColor =
config?.cameras?.[review.camera]?.zones?.[zoneName]?.color; config?.cameras?.[event.camera]?.zones?.[zoneName]?.color;
if (zoneColor) { if (zoneColor) {
const reversed = [...zoneColor].reverse(); const reversed = [...zoneColor].reverse();
return reversed; return reversed;
} }
}, },
[config, review], [config, event],
); );
const getZonePolygon = useCallback( const getZonePolygon = useCallback(
@ -93,7 +95,7 @@ export default function ObjectLifecycle({
return; return;
} }
const zonePoints = const zonePoints =
config?.cameras[review.camera].zones[zoneName].coordinates; config?.cameras[event.camera].zones[zoneName].coordinates;
const imgElement = imgRef.current; const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect(); const imgRect = imgElement.getBoundingClientRect();
@ -110,7 +112,7 @@ export default function ObjectLifecycle({
}, [] as number[]) }, [] as number[])
.join(","); .join(",");
}, },
[config, imgRef, review], [config, imgRef, event],
); );
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null); const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
@ -224,19 +226,21 @@ export default function ObjectLifecycle({
} }
return ( return (
<> <div className={className}>
<div className={cn("flex items-center gap-2")}> {!fullscreen && (
<Button <div className={cn("flex items-center gap-2")}>
className="flex items-center gap-2.5 rounded-lg" <Button
size="sm" className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
onClick={() => setPane("overview")} size="sm"
> onClick={() => setPane("overview")}
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> >
{isDesktop && <div className="text-primary">Back</div>} <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
</Button> {isDesktop && <div className="text-primary">Back</div>}
</div> </Button>
</div>
)}
<div className="relative mx-auto"> <div className="relative flex flex-row justify-center">
<ImageLoadingIndicator <ImageLoadingIndicator
className="absolute inset-0" className="absolute inset-0"
imgLoaded={imgLoaded} imgLoaded={imgLoaded}
@ -249,7 +253,12 @@ export default function ObjectLifecycle({
</div> </div>
</div> </div>
)} )}
<div className={cn(imgLoaded ? "visible" : "invisible")}> <div
className={cn(
"relative inline-block",
imgLoaded ? "visible" : "invisible",
)}
>
<img <img
key={event.id} key={event.id}
ref={imgRef} ref={imgRef}
@ -274,7 +283,7 @@ export default function ObjectLifecycle({
{showZones && {showZones &&
lifecycleZones?.map((zone) => ( lifecycleZones?.map((zone) => (
<div <div
className="absolute left-0 top-0" className="absolute inset-0 flex items-center justify-center"
style={{ style={{
width: imgRef.current?.clientWidth, width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight, height: imgRef.current?.clientHeight,
@ -283,6 +292,7 @@ export default function ObjectLifecycle({
> >
<svg <svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`} viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
> >
<polygon <polygon
points={getZonePolygon(zone)} points={getZonePolygon(zone)}
@ -347,7 +357,10 @@ export default function ObjectLifecycle({
)} )}
<div className="relative flex flex-col items-center justify-center"> <div className="relative flex flex-col items-center justify-center">
<Carousel className="m-0 w-full" setApi={setMainApi}> <Carousel
className={cn("m-0 w-full", fullscreen && isDesktop && "w-[75%]")}
setApi={setMainApi}
>
<CarouselContent> <CarouselContent>
{eventSequence.map((item, index) => ( {eventSequence.map((item, index) => (
<CarouselItem key={index}> <CarouselItem key={index}>
@ -455,7 +468,7 @@ export default function ObjectLifecycle({
</CarouselContent> </CarouselContent>
</Carousel> </Carousel>
</div> </div>
<div className="relative flex flex-col items-center justify-center"> <div className="relative mt-4 flex flex-col items-center justify-center">
<Carousel <Carousel
opts={{ opts={{
align: "center", align: "center",
@ -474,7 +487,10 @@ export default function ObjectLifecycle({
{eventSequence.map((item, index) => ( {eventSequence.map((item, index) => (
<CarouselItem <CarouselItem
key={index} key={index}
className={cn("basis-1/4 cursor-pointer pl-1 md:basis-[10%]")} className={cn(
"basis-1/4 cursor-pointer pl-1 md:basis-[10%]",
fullscreen && "md:basis-16",
)}
onClick={() => handleThumbnailClick(index)} onClick={() => handleThumbnailClick(index)}
> >
<div className="p-1"> <div className="p-1">
@ -513,7 +529,7 @@ export default function ObjectLifecycle({
<CarouselNext /> <CarouselNext />
</Carousel> </Carousel>
</div> </div>
</> </div>
); );
} }

View File

@ -6,13 +6,6 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "../../ui/sheet"; } from "../../ui/sheet";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "../../ui/drawer";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
@ -20,12 +13,12 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; import { ReviewDetailPaneType, ReviewSegment } from "@/types/review";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import ObjectLifecycle from "./ObjectLifecycle"; import ObjectLifecycle from "./ObjectLifecycle";
import Chip from "@/components/indicators/Chip"; import Chip from "@/components/indicators/Chip";
import { FaDownload, FaImages } from "react-icons/fa"; import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FaArrowsRotate } from "react-icons/fa6"; import { FaArrowsRotate } from "react-icons/fa6";
import { import {
@ -34,6 +27,16 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { baseUrl } from "@/api/baseUrl";
import { shareOrCopy } from "@/utils/browserUtil";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
type ReviewDetailDialogProps = { type ReviewDetailDialogProps = {
review?: ReviewSegment; review?: ReviewSegment;
@ -70,6 +73,7 @@ export default function ReviewDetailDialog({
config?.ui.time_format == "24hour" config?.ui.time_format == "24hour"
? "%b %-d %Y, %H:%M" ? "%b %-d %Y, %H:%M"
: "%b %-d %Y, %I:%M %p", : "%b %-d %Y, %I:%M %p",
config?.ui.timezone,
); );
// content // content
@ -77,11 +81,19 @@ export default function ReviewDetailDialog({
const [selectedEvent, setSelectedEvent] = useState<Event>(); const [selectedEvent, setSelectedEvent] = useState<Event>();
const [pane, setPane] = useState<ReviewDetailPaneType>("overview"); const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
const Overlay = isDesktop ? Sheet : Drawer; // dialog and mobile page
const Content = isDesktop ? SheetContent : DrawerContent;
const Header = isDesktop ? SheetHeader : DrawerHeader; const [isOpen, setIsOpen] = useState(review != undefined);
const Title = isDesktop ? SheetTitle : DrawerTitle;
const Description = isDesktop ? SheetDescription : DrawerDescription; useEffect(() => {
setIsOpen(review != undefined);
}, [review]);
const Overlay = isDesktop ? Sheet : MobilePage;
const Content = isDesktop ? SheetContent : MobilePageContent;
const Header = isDesktop ? SheetHeader : MobilePageHeader;
const Title = isDesktop ? SheetTitle : MobilePageTitle;
const Description = isDesktop ? SheetDescription : MobilePageDescription;
if (!review) { if (!review) {
return; return;
@ -90,7 +102,7 @@ export default function ReviewDetailDialog({
return ( return (
<> <>
<Overlay <Overlay
open={review != undefined} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setReview(undefined); setReview(undefined);
@ -111,19 +123,43 @@ export default function ReviewDetailDialog({
<Content <Content
className={cn( className={cn(
isDesktop "scrollbar-container overflow-y-auto",
? pane == "overview" isDesktop && pane == "overview"
? "sm:max-w-xl" ? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl" : "pt-2 sm:max-w-4xl",
: "max-h-[80dvh] overflow-hidden p-2 pb-4", isMobile && "px-4",
)} )}
> >
<Header className="sr-only"> <span tabIndex={0} className="sr-only" />
<Title>Review Item Details</Title>
<Description>Review item details</Description>
</Header>
{pane == "overview" && ( {pane == "overview" && (
<div className="scrollbar-container mt-3 flex size-full flex-col gap-5 overflow-y-auto"> <Header className="justify-center" onClose={() => setIsOpen(false)}>
<Title>Review Item Details</Title>
<Description className="sr-only">Review item details</Description>
<div
className={cn(
"absolute",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
>
<Tooltip>
<TooltipTrigger>
<Button
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Share this review item</TooltipContent>
</Tooltip>
</div>
</Header>
)}
{pane == "overview" && (
<div className="flex flex-col gap-5 md:mt-3">
<div className="flex w-full flex-row"> <div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -137,10 +173,10 @@ export default function ReviewDetailDialog({
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col items-center gap-2">
<div className="flex flex-col gap-1.5"> <div className="flex w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">Objects</div> <div className="text-sm text-primary/40">Objects</div>
<div className="flex flex-col items-start gap-2 text-sm capitalize"> <div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
{events?.map((event) => { {events?.map((event) => {
return ( return (
<div <div
@ -159,7 +195,7 @@ export default function ReviewDetailDialog({
</div> </div>
</div> </div>
{review.data.zones.length > 0 && ( {review.data.zones.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">Zones</div> <div className="text-sm text-primary/40">Zones</div>
<div className="flex flex-col items-start gap-2 text-sm capitalize"> <div className="flex flex-col items-start gap-2 text-sm capitalize">
{review.data.zones.map((zone) => { {review.data.zones.map((zone) => {
@ -199,11 +235,7 @@ export default function ReviewDetailDialog({
{pane == "details" && selectedEvent && ( {pane == "details" && selectedEvent && (
<div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden">
<ObjectLifecycle <ObjectLifecycle event={selectedEvent} setPane={setPane} />
review={review}
event={selectedEvent}
setPane={setPane}
/>
</div> </div>
)} )}
</Content> </Content>

View File

@ -1,11 +1,4 @@
import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { isDesktop, isIOS, isMobile } from "react-device-detect";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "../../ui/drawer";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -34,10 +27,23 @@ import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { FaRegListAlt, FaVideo } from "react-icons/fa"; import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import { FaRotate } from "react-icons/fa6";
import ObjectLifecycle from "./ObjectLifecycle";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
const SEARCH_TABS = ["details", "frigate+", "video"] as const; const SEARCH_TABS = [
"details",
"snapshot",
"video",
"object lifecycle",
] as const;
type SearchTab = (typeof SEARCH_TABS)[number]; type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
@ -59,6 +65,14 @@ export default function SearchDetailDialog({
const [page, setPage] = useState<SearchTab>("details"); const [page, setPage] = useState<SearchTab>("details");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
// dialog and mobile page
const [isOpen, setIsOpen] = useState(search != undefined);
useEffect(() => {
setIsOpen(search != undefined);
}, [search]);
const searchTabs = useMemo(() => { const searchTabs = useMemo(() => {
if (!config || !search) { if (!config || !search) {
return []; return [];
@ -66,8 +80,8 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS]; const views = [...SEARCH_TABS];
if (!config.plus.enabled || !search.has_snapshot) { if (!search.has_snapshot) {
const index = views.indexOf("frigate+"); const index = views.indexOf("snapshot");
views.splice(index, 1); views.splice(index, 1);
} }
@ -80,21 +94,31 @@ export default function SearchDetailDialog({
return views; return views;
}, [config, search]); }, [config, search]);
useEffect(() => {
if (searchTabs.length == 0) {
return;
}
if (!searchTabs.includes(pageToggle)) {
setPage("details");
}
}, [pageToggle, searchTabs]);
if (!search) { if (!search) {
return; return;
} }
// content // content
const Overlay = isDesktop ? Dialog : Drawer; const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : DrawerContent; const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : DrawerHeader; const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Title = isDesktop ? DialogTitle : DrawerTitle; const Title = isDesktop ? DialogTitle : MobilePageTitle;
const Description = isDesktop ? DialogDescription : DrawerDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<Overlay <Overlay
open={search != undefined} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setSearch(undefined); setSearch(undefined);
@ -102,15 +126,16 @@ export default function SearchDetailDialog({
}} }}
> >
<Content <Content
className={ className={cn(
isDesktop "scrollbar-container overflow-y-auto",
? "sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-7xl" isDesktop &&
: "max-h-[75dvh] overflow-hidden px-2 pb-4" "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
} isMobile && "px-4",
)}
> >
<Header className="sr-only"> <Header onClose={() => setIsOpen(false)}>
<Title>Tracked Object Details</Title> <Title>Tracked Object Details</Title>
<Description>Tracked object details</Description> <Description className="sr-only">Tracked object details</Description>
</Header> </Header>
<ScrollArea <ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")} className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
@ -136,8 +161,11 @@ export default function SearchDetailDialog({
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
{item == "details" && <FaRegListAlt className="size-4" />} {item == "details" && <FaRegListAlt className="size-4" />}
{item == "frigate+" && <FrigatePlusIcon className="size-4" />} {item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />} {item == "video" && <FaVideo className="size-4" />}
{item == "object lifecycle" && (
<FaRotate className="size-4" />
)}
<div className="capitalize">{item}</div> <div className="capitalize">{item}</div>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
@ -153,9 +181,14 @@ export default function SearchDetailDialog({
setSimilarity={setSimilarity} setSimilarity={setSimilarity}
/> />
)} )}
{page == "frigate+" && ( {page == "snapshot" && (
<FrigatePlusDialog <FrigatePlusDialog
upload={search as unknown as Event} upload={
{
...search,
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
} as unknown as Event
}
dialog={false} dialog={false}
onClose={() => {}} onClose={() => {}}
onEventUploaded={() => { onEventUploaded={() => {
@ -164,6 +197,14 @@ export default function SearchDetailDialog({
/> />
)} )}
{page == "video" && <VideoTab search={search} config={config} />} {page == "video" && <VideoTab search={search} config={config} />}
{page == "object lifecycle" && (
<ObjectLifecycle
className="w-full"
event={search as unknown as Event}
fullscreen={true}
setPane={() => {}}
/>
)}
</Content> </Content>
</Overlay> </Overlay>
); );
@ -195,6 +236,7 @@ function ObjectDetailsTab({
config?.ui.time_format == "24hour" config?.ui.time_format == "24hour"
? "%b %-d %Y, %H:%M" ? "%b %-d %Y, %H:%M"
: "%b %-d %Y, %I:%M %p", : "%b %-d %Y, %I:%M %p",
config?.ui.timezone,
); );
const score = useMemo(() => { const score = useMemo(() => {
@ -242,7 +284,7 @@ function ObjectDetailsTab({
}, [desc, search]); }, [desc, search]);
return ( return (
<div className="mt-3 flex size-full flex-col gap-5 md:mt-0"> <div className="flex flex-col gap-5">
<div className="flex w-full flex-row"> <div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -270,7 +312,7 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-2 px-6"> <div className="flex w-full flex-col gap-2 pl-6">
<img <img
className="aspect-video select-none rounded-lg object-contain transition-opacity" className="aspect-video select-none rounded-lg object-contain transition-opacity"
style={ style={

View File

@ -13,6 +13,7 @@ import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { isDesktop } from "react-device-detect";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr"; import useSWR from "swr";
@ -34,6 +35,9 @@ export function FrigatePlusDialog({
// layout // layout
const Title = isDesktop ? DialogTitle : "div";
const Description = isDesktop ? DialogDescription : "div";
const grow = useMemo(() => { const grow = useMemo(() => {
if (!config || !upload) { if (!config || !upload) {
return ""; return "";
@ -79,60 +83,74 @@ export function FrigatePlusDialog({
const content = ( const content = (
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}> <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<DialogHeader> <div className="flex flex-col space-y-3">
<DialogTitle>Submit To Frigate+</DialogTitle> <DialogHeader
<DialogDescription> className={state == "submitted" ? "sr-only" : "text-left"}
Objects in locations you want to avoid are not false positives. >
Submitting them as false positives will confuse the model. <Title
</DialogDescription> className={
</DialogHeader> !isDesktop
<TransformComponent ? "text-lg font-semibold leading-none tracking-tight"
wrapperStyle={{ : undefined
width: "100%", }
height: "100%", >
}} Submit To Frigate+
contentStyle={{ </Title>
position: "relative", <Description
width: "100%", className={!isDesktop ? "text-sm text-muted-foreground" : undefined}
height: "100%", >
}} Objects in locations you want to avoid are not false positives.
> Submitting them as false positives will confuse the model.
{upload?.id && ( </Description>
<img </DialogHeader>
className={`w-full ${grow} bg-black`} <TransformComponent
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`} wrapperStyle={{
alt={`${upload?.label}`} width: "100%",
/> height: "100%",
)} }}
</TransformComponent> contentStyle={{
position: "relative",
width: "100%",
height: "100%",
}}
>
{upload?.id && (
<img
className={`w-full ${grow} bg-black`}
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
alt={`${upload?.label}`}
/>
)}
</TransformComponent>
<DialogFooter> <DialogFooter className="flex flex-row justify-end gap-2">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
{dialog && <Button onClick={onClose}>Cancel</Button>} {dialog && <Button onClick={onClose}>Cancel</Button>}
<Button <Button
className="bg-success" className="bg-success"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");
onSubmitToPlus(false); onSubmitToPlus(false);
}} }}
> >
This is a {upload?.label} This is a {upload?.label}
</Button> </Button>
<Button <Button
className="text-white" className="text-white"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setState("uploading"); setState("uploading");
onSubmitToPlus(true); onSubmitToPlus(true);
}} }}
> >
This is not a {upload?.label} This is not a {upload?.label}
</Button> </Button>
</> </>
)} )}
{state == "uploading" && <ActivityIndicator />} {state == "uploading" && <ActivityIndicator />}
</DialogFooter> </DialogFooter>
</div>
</TransformWrapper> </TransformWrapper>
); );

View File

@ -539,6 +539,7 @@ function PreviewFramesPlayer({
<img <img
ref={imgRef} ref={imgRef}
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`} className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`}
loading="lazy"
onLoad={onImageLoaded} onLoad={onImageLoaded}
/> />
{previewFrames?.length === 0 && ( {previewFrames?.length === 0 && (

View File

@ -168,6 +168,7 @@ export default function PreviewThumbnailPlayer({
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
review.start_time, review.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
config?.ui?.timezone,
); );
return ( return (

View File

@ -373,7 +373,7 @@ export default function ZoneEditPane({
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter a name..." placeholder="Enter a name..."
{...field} {...field}
/> />
@ -395,7 +395,7 @@ export default function ZoneEditPane({
<FormLabel>Inertia</FormLabel> <FormLabel>Inertia</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="3" placeholder="3"
{...field} {...field}
/> />
@ -417,7 +417,7 @@ export default function ZoneEditPane({
<FormLabel>Loitering Time</FormLabel> <FormLabel>Loitering Time</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0" placeholder="0"
{...field} {...field}
/> />

View File

@ -65,11 +65,11 @@ export function useApiFilterArgs<
const filter: { [key: string]: unknown } = {}; const filter: { [key: string]: unknown } = {};
rawParams.forEach((value, key) => { rawParams.forEach((value, key) => {
if (isNaN(parseFloat(value))) { if (value != "true" && value != "false" && isNaN(parseFloat(value))) {
filter[key] = value.includes(",") ? value.split(",") : [value]; filter[key] = value.includes(",") ? value.split(",") : [value];
} else { } else {
if (value != undefined) { if (value != undefined) {
filter[key] = `${value}`; filter[key] = JSON.parse(value);
} }
} }
}); });

View File

@ -2,12 +2,17 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useMemo } from "react"; import { useMemo } from "react";
export function useFormattedTimestamp(timestamp: number, format: string) { export function useFormattedTimestamp(
timestamp: number,
format: string,
timezone?: string,
) {
const formattedTimestamp = useMemo(() => { const formattedTimestamp = useMemo(() => {
return formatUnixTimestampToDateTime(timestamp, { return formatUnixTimestampToDateTime(timestamp, {
timezone,
strftime_fmt: format, strftime_fmt: format,
}); });
}, [format, timestamp]); }, [format, timestamp, timezone]);
return formattedTimestamp; return formattedTimestamp;
} }

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence"; import { usePersistence } from "./use-persistence";
export function useOverlayState<S>( export function useOverlayState<S>(
@ -103,33 +103,29 @@ export function useHashState<S extends string>(): [
export function useSearchEffect( export function useSearchEffect(
key: string, key: string,
callback: (value: string) => void, callback: (value: string) => boolean,
) { ) {
const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams();
const param = useMemo(() => { const param = useMemo(() => {
if (!location || !location.search || location.search.length == 0) { const param = searchParams.get(key);
if (!param) {
return undefined; return undefined;
} }
const params = location.search.substring(1).split("&"); return [key, decodeURIComponent(param)];
}, [searchParams, key]);
const foundParam = params
.find((p) => p.includes("=") && p.split("=")[0] == key)
?.split("=");
if (foundParam && foundParam.length === 2) {
return [foundParam[0], decodeURIComponent(foundParam[1])];
}
return undefined;
}, [location, key]);
useEffect(() => { useEffect(() => {
if (!param) { if (!param) {
return; return;
} }
callback(param[1]); const remove = callback(param[1]);
}, [param, callback]);
if (remove) {
setSearchParams();
}
}, [param, callback, setSearchParams]);
} }

View File

@ -1,5 +1,5 @@
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import useApiFilter from "@/hooks/use-api-filter"; import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
@ -54,6 +54,8 @@ export default function Events() {
} }
}) })
.catch(() => {}); .catch(() => {});
return true;
}); });
const [startTime, setStartTime] = useState<number>(); const [startTime, setStartTime] = useState<number>();
@ -69,7 +71,7 @@ export default function Events() {
// review filter // review filter
const [reviewFilter, setReviewFilter, reviewSearchParams] = const [reviewFilter, setReviewFilter, reviewSearchParams] =
useApiFilter<ReviewFilter>(); useApiFilterArgs<ReviewFilter>();
useSearchEffect("group", (reviewGroup) => { useSearchEffect("group", (reviewGroup) => {
if (config && reviewGroup && reviewGroup != "default") { if (config && reviewGroup && reviewGroup != "default") {
@ -83,7 +85,11 @@ export default function Events() {
cameras: group.cameras, cameras: group.cameras,
}); });
} }
return true;
} }
return false;
}); });
const onUpdateFilter = useCallback( const onUpdateFilter = useCallback(

View File

@ -43,6 +43,7 @@ export default function Explore() {
setSearch(`similarity:${similarityId}`); setSearch(`similarity:${similarityId}`);
// @ts-expect-error we want to clear this // @ts-expect-error we want to clear this
setSearchFilter({ ...searchFilter, similarity_search_id: undefined }); setSearchFilter({ ...searchFilter, similarity_search_id: undefined });
return false;
}); });
useEffect(() => { useEffect(() => {

View File

@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export"; import { DeleteClipType, Export } from "@/types/export";
import axios from "axios"; import axios from "axios";
@ -46,6 +47,20 @@ function Exports() {
); );
}, [exports, search]); }, [exports, search]);
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedAspect, setSelectedAspect] = useState(0.0);
useSearchEffect("id", (id) => {
if (!exports) {
return false;
}
setSelected(exports.find((exp) => exp.id == id));
return true;
});
// Deleting // Deleting
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>(); const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
@ -91,11 +106,6 @@ function Exports() {
[mutate], [mutate],
); );
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedAspect, setSelectedAspect] = useState(0.0);
return ( return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
@ -165,7 +175,7 @@ function Exports() {
{exports && ( {exports && (
<div className="flex w-full items-center justify-center p-2"> <div className="flex w-full items-center justify-center p-2">
<Input <Input
className="w-full bg-muted md:w-1/3" className="text-md w-full bg-muted md:w-1/3"
placeholder="Search" placeholder="Search"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}

View File

@ -29,7 +29,11 @@ function Live() {
if (group) { if (group) {
setCameraGroup(cameraGroup); setCameraGroup(cameraGroup);
} }
return true;
} }
return false;
}); });
// fullscreen // fullscreen

View File

@ -0,0 +1,16 @@
import copy from "copy-to-clipboard";
import { toast } from "sonner";
export function shareOrCopy(url: string, title?: string) {
if (window.isSecureContext && "share" in navigator) {
navigator.share({
url: url,
title: title,
});
} else {
copy(url);
toast.success("Copied URL to clipboard.", {
position: "top-center",
});
}
}

View File

@ -641,7 +641,7 @@ function DetectionReview({
> >
{filter?.before == undefined && ( {filter?.before == undefined && (
<NewReviewData <NewReviewData
className="pointer-events-none absolute left-1/2 z-50 -translate-x-1/2" className="pointer-events-none absolute left-1/2 z-[49] -translate-x-1/2"
contentRef={contentRef} contentRef={contentRef}
reviewItems={currentItems} reviewItems={currentItems}
itemsToReview={loading ? 0 : itemsToReview} itemsToReview={loading ? 0 : itemsToReview}

View File

@ -17,7 +17,7 @@ import useImageLoaded from "@/hooks/use-image-loaded";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
type ExploreViewProps = { type ExploreViewProps = {
onSelectSearch: (searchResult: SearchResult) => void; onSelectSearch: (searchResult: SearchResult, index: number) => void;
}; };
export default function ExploreView({ onSelectSearch }: ExploreViewProps) { export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
@ -60,7 +60,7 @@ export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
} }
return ( return (
<div className="scrollbar-container mx-2 space-y-4 overflow-x-hidden"> <div className="mx-2 space-y-4">
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => ( {Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
<ThumbnailRow <ThumbnailRow
key={label} key={label}
@ -76,7 +76,7 @@ export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
type ThumbnailRowType = { type ThumbnailRowType = {
objectType: string; objectType: string;
searchResults?: SearchResult[]; searchResults?: SearchResult[];
onSelectSearch: (searchResult: SearchResult) => void; onSelectSearch: (searchResult: SearchResult, index: number) => void;
}; };
function ThumbnailRow({ function ThumbnailRow({
@ -145,7 +145,7 @@ function ThumbnailRow({
type ExploreThumbnailImageProps = { type ExploreThumbnailImageProps = {
event: SearchResult; event: SearchResult;
onSelectSearch: (searchResult: SearchResult) => void; onSelectSearch: (searchResult: SearchResult, index: number) => void;
}; };
function ExploreThumbnailImage({ function ExploreThumbnailImage({
event, event,
@ -176,7 +176,7 @@ function ExploreThumbnailImage({
loading={isSafari ? "eager" : "lazy"} loading={isSafari ? "eager" : "lazy"}
draggable={false} draggable={false}
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
onClick={() => onSelectSearch(event)} onClick={() => onSelectSearch(event, 0)}
onLoad={() => { onLoad={() => {
onImgLoad(); onImgLoad();
}} }}

View File

@ -13,11 +13,15 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult } from "@/types/search"; import { SearchFilter, SearchResult } from "@/types/search";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu"; import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
import useSWR from "swr"; import useSWR from "swr";
import ExploreView from "../explore/ExploreView"; import ExploreView from "../explore/ExploreView";
import useKeyboardListener, {
KeyModifiers,
} from "@/hooks/use-keyboard-listener";
import scrollIntoView from "scroll-into-view-if-needed";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -59,8 +63,12 @@ export default function SearchView({
// search interaction // search interaction
const onSelectSearch = useCallback((item: SearchResult) => { const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback((item: SearchResult, index: number) => {
setSearchDetail(item); setSearchDetail(item);
setSelectedIndex(index);
}, []); }, []);
// confidence score - probably needs tweaking // confidence score - probably needs tweaking
@ -87,6 +95,56 @@ export default function SearchView({
[searchResults, searchFilter], [searchResults, searchFilter],
); );
// keyboard listener
const onKeyboardShortcut = useCallback(
(key: string | null, modifiers: KeyModifiers) => {
if (!modifiers.down || !uniqueResults) {
return;
}
switch (key) {
case "ArrowLeft":
setSelectedIndex((prevIndex) => {
const newIndex =
prevIndex === null
? uniqueResults.length - 1
: (prevIndex - 1 + uniqueResults.length) % uniqueResults.length;
setSearchDetail(uniqueResults[newIndex]);
return newIndex;
});
break;
case "ArrowRight":
setSelectedIndex((prevIndex) => {
const newIndex =
prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length;
setSearchDetail(uniqueResults[newIndex]);
return newIndex;
});
break;
}
},
[uniqueResults],
);
useKeyboardListener(["ArrowLeft", "ArrowRight"], onKeyboardShortcut);
// scroll into view
useEffect(() => {
if (
selectedIndex !== null &&
uniqueResults &&
itemRefs.current?.[selectedIndex]
) {
scrollIntoView(itemRefs.current[selectedIndex], {
block: "center",
behavior: "smooth",
scrollMode: "if-needed",
});
}
}, [selectedIndex, uniqueResults]);
return ( return (
<div className="flex size-full flex-col pt-2 md:py-2"> <div className="flex size-full flex-col pt-2 md:py-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
@ -156,12 +214,13 @@ export default function SearchView({
{uniqueResults && ( {uniqueResults && (
<div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6"> <div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
{uniqueResults && {uniqueResults &&
uniqueResults.map((value) => { uniqueResults.map((value, index) => {
const selected = false; const selected = selectedIndex === index;
return ( return (
<div <div
key={value.id} key={value.id}
ref={(item) => (itemRefs.current[index] = item)}
data-start={value.start_time} data-start={value.start_time}
className="review-item relative rounded-lg" className="review-item relative rounded-lg"
> >
@ -173,7 +232,7 @@ export default function SearchView({
<SearchThumbnail <SearchThumbnail
searchResult={value} searchResult={value}
findSimilar={() => setSimilaritySearch(value)} findSimilar={() => setSimilaritySearch(value)}
onClick={() => onSelectSearch(value)} onClick={() => onSelectSearch(value, index)}
/> />
{searchTerm && ( {searchTerm && (
<div className={cn("absolute right-2 top-2 z-40")}> <div className={cn("absolute right-2 top-2 z-40")}>
@ -207,7 +266,7 @@ export default function SearchView({
)} )}
</div> </div>
<div <div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`} className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
/> />
</div> </div>
); );
@ -215,7 +274,7 @@ export default function SearchView({
</div> </div>
)} )}
{!uniqueResults && !isLoading && ( {!uniqueResults && !isLoading && (
<div className="flex size-full flex-col"> <div className="scrollbar-container flex size-full flex-col overflow-y-auto">
<ExploreView onSelectSearch={onSelectSearch} /> <ExploreView onSelectSearch={onSelectSearch} />
</div> </div>
)} )}

View File

@ -254,7 +254,7 @@ export default function NotificationView({
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder="example@email.com" placeholder="example@email.com"
{...field} {...field}
/> />