mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 15:15:22 +03:00
Merge branch 'dev' into fastapi-poc
This commit is contained in:
commit
b87fc3304c
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
121
web/src/components/mobile/MobilePage.tsx
Normal file
121
web/src/components/mobile/MobilePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -29,7 +29,11 @@ function Live() {
|
|||||||
if (group) {
|
if (group) {
|
||||||
setCameraGroup(cameraGroup);
|
setCameraGroup(cameraGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// fullscreen
|
// fullscreen
|
||||||
|
|||||||
16
web/src/utils/browserUtil.ts
Normal file
16
web/src/utils/browserUtil.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user