Merge branch 'fastapi-poc' into fastapi-poc-media-endpoints

# Conflicts:
#	web/src/components/overlay/dialog/FrigatePlusDialog.tsx
This commit is contained in:
Rui Alves 2024-09-13 08:52:44 +01:00
commit 2187765e0b
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" && (
<> <>
<div className="flex flex-row items-center justify-between py-2"> <Header
<DialogTitle>Camera Groups</DialogTitle> className={cn(isDesktop && "mt-5", "justify-center")}
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,16 +372,26 @@ 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"
onClose={() => {
setEditState("none");
setEditingGroupName("");
}}
>
<Title>
{editState == "add" ? "Add" : "Edit"} Camera Group {editState == "add" ? "Add" : "Edit"} Camera Group
</DialogTitle> </Title>
</div> <Description className="sr-only">
Edit camera groups
</Description>
</Header>
<CameraGroupEdit <CameraGroupEdit
currentGroups={currentGroups} currentGroups={currentGroups}
editingGroup={editingGroup} editingGroup={editingGroup}
@ -353,7 +402,6 @@ function NewGroupDialog({
/> />
</> </>
)} )}
</div>
</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>
<DropdownMenuPortal>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={onEditGroup}>Edit</DropdownMenuItem> <DropdownMenuItem onClick={onEditGroup}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}> <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </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,10 +226,11 @@ export default function ObjectLifecycle({
} }
return ( return (
<> <div className={className}>
{!fullscreen && (
<div className={cn("flex items-center gap-2")}> <div className={cn("flex items-center gap-2")}>
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
size="sm" size="sm"
onClick={() => setPane("overview")} onClick={() => setPane("overview")}
> >
@ -235,8 +238,9 @@ export default function ObjectLifecycle({
{isDesktop && <div className="text-primary">Back</div>} {isDesktop && <div className="text-primary">Back</div>}
</Button> </Button>
</div> </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,12 +83,25 @@ 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"}
>
<Title
className={
!isDesktop
? "text-lg font-semibold leading-none tracking-tight"
: undefined
}
>
Submit To Frigate+
</Title>
<Description
className={!isDesktop ? "text-sm text-muted-foreground" : undefined}
>
Objects in locations you want to avoid are not false positives. Objects in locations you want to avoid are not false positives.
Submitting them as false positives will confuse the model. Submitting them as false positives will confuse the model.
</DialogDescription> </Description>
</DialogHeader> </DialogHeader>
<TransformComponent <TransformComponent
wrapperStyle={{ wrapperStyle={{
@ -106,7 +123,7 @@ export function FrigatePlusDialog({
)} )}
</TransformComponent> </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>}
@ -133,6 +150,7 @@ export function FrigatePlusDialog({
)} )}
{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}
/> />