refactoring

add translates
finish recordings
This commit is contained in:
NlightN22 2024-02-25 21:15:31 +07:00
parent a971ea55a4
commit 7304fe231e
69 changed files with 1682 additions and 1584 deletions

View File

@ -1,6 +1,6 @@
import { Button, Flex, Text } from '@mantine/core'; import { Button, Flex, Text } from '@mantine/core';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import CogWheelWithText from '../shared/components/CogWheelWithText'; import CogWheelWithText from '../shared/components/loaders/CogWheelWithText';
import { strings } from '../shared/strings/strings'; import { strings } from '../shared/strings/strings';
import { redirect, useNavigate } from 'react-router-dom'; import { redirect, useNavigate } from 'react-router-dom';
import { routesPath } from '../router/routes.path'; import { routesPath } from '../router/routes.path';

View File

@ -1,6 +1,6 @@
import { Button, Flex, Text } from '@mantine/core'; import { Button, Flex, Text } from '@mantine/core';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import CogWheelWithText from '../shared/components/CogWheelWithText'; import CogWheelWithText from '../shared/components/loaders/CogWheelWithText';
import { strings } from '../shared/strings/strings'; import { strings } from '../shared/strings/strings';
import { redirect, useNavigate } from 'react-router-dom'; import { redirect, useNavigate } from 'react-router-dom';
import { routesPath } from '../router/routes.path'; import { routesPath } from '../router/routes.path';

View File

@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import { deleteFrigateHostSchema, GetFrigateHost, putFrigateHostSchema} from '../services/frigate.proxy/frigate.schema'; import { deleteFrigateHostSchema, GetFrigateHost, putFrigateHostSchema} from '../services/frigate.proxy/frigate.schema';
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import RetryError from './RetryError'; import RetryErrorPage from './RetryErrorPage';
import { Context } from '..'; import { Context } from '..';
import { strings } from '../shared/strings/strings'; import { strings } from '../shared/strings/strings';
import { Button, Flex } from '@mantine/core'; import { Button, Flex } from '@mantine/core';
@ -71,7 +71,7 @@ const FrigateHostsPage = observer(() => {
} }
if (hostsPending) return <CenterLoader /> if (hostsPending) return <CenterLoader />
if (hostsError) return <RetryError /> if (hostsError) return <RetryErrorPage />
return ( return (
<div> <div>
{ {

View File

@ -9,7 +9,7 @@ import { configureMonacoYaml } from "monaco-yaml";
import Editor, { DiffEditor, useMonaco, loader, Monaco } from '@monaco-editor/react' import Editor, { DiffEditor, useMonaco, loader, Monaco } from '@monaco-editor/react'
import * as monaco from "monaco-editor"; import * as monaco from "monaco-editor";
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import RetryError from './RetryError'; import RetryErrorPage from './RetryErrorPage';
const HostConfigPage = () => { const HostConfigPage = () => {
@ -74,7 +74,7 @@ const HostConfigPage = () => {
if (configPending) return <CenterLoader /> if (configPending) return <CenterLoader />
if (configError) return <RetryError onRetry={refetch} /> if (configError) return <RetryErrorPage onRetry={refetch} />
return ( return (
<Flex direction='column' h='100%' w='100%' justify='stretch'> <Flex direction='column' h='100%' w='100%' justify='stretch'>

View File

@ -5,8 +5,8 @@ import { useParams } from 'react-router-dom';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import RetryError from './RetryError'; import RetryErrorPage from './RetryErrorPage';
import Player from '../shared/components/frigate/Player'; import Player from '../widgets/Player';
import { Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
const LiveCameraPage = observer(() => { const LiveCameraPage = observer(() => {
@ -28,7 +28,7 @@ const LiveCameraPage = observer(() => {
if (isPending) return <CenterLoader /> if (isPending) return <CenterLoader />
if (isError) return <RetryError onRetry={refetch} /> if (isError) return <RetryErrorPage onRetry={refetch} />
return ( return (
<Flex w='100%' h='100%' justify='center'> <Flex w='100%' h='100%' justify='center'>

View File

@ -8,11 +8,12 @@ import { observer } from 'mobx-react-lite'
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import RetryError from './RetryError'; import RetryErrorPage from './RetryErrorPage';
import CameraCard from '../shared/components/CameraCard'; import CameraCard from '../shared/components/CameraCard';
const MainBody = observer(() => { const MainPage = () => {
const { sideBarsStore } = useContext(Context) const { sideBarsStore } = useContext(Context)
useEffect(() => { useEffect(() => {
sideBarsStore.rightVisible = false sideBarsStore.rightVisible = false
sideBarsStore.setLeftChildren(null) sideBarsStore.setLeftChildren(null)
@ -32,11 +33,11 @@ const MainBody = observer(() => {
if (isPending) return <CenterLoader /> if (isPending) return <CenterLoader />
if (isError) return <RetryError onRetry={refetch} /> if (isError) return <RetryErrorPage onRetry={refetch} />
const cards = () => { const cards = () => {
// return cameras.filter(cam => cam.frigateHost?.host.includes('5001')).slice(0,1).map(camera => ( return cameras.filter(cam => cam.frigateHost?.host.includes('5000')).slice(0,25).map(camera => (
return cameras.map(camera => ( // return cameras.map(camera => (
<CameraCard <CameraCard
key={camera.id} key={camera.id}
camera={camera} camera={camera}
@ -70,6 +71,6 @@ const MainBody = observer(() => {
</Flex> </Flex>
</Flex> </Flex>
); );
}) }
export default MainBody; export default observer(MainPage);

View File

@ -9,12 +9,18 @@ import SelectedCameraList from '../widgets/SelectedCameraList';
import SelectedHostList from '../widgets/SelectedHostList'; import SelectedHostList from '../widgets/SelectedHostList';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import { dateToQueryString, parseQueryDateToDate } from '../shared/utils/dateUtil';
import SelecteDayList from '../widgets/SelecteDayList';
import { useDebouncedValue } from '@mantine/hooks';
import CogwheelLoader from '../shared/components/loaders/CogwheelLoader';
import CenterLoader from '../shared/components/CenterLoader';
const recordingsQuery = { export const recordingsPageQuery = {
hostId: 'hostId', hostId: 'hostId',
cameraId: 'cameraId', cameraId: 'cameraId',
date: 'date', startDay: 'startDay',
endDay: 'endDay',
hour: 'hour', hour: 'hour',
} }
@ -24,13 +30,16 @@ const RecordingsPage = observer(() => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const queryParams = new URLSearchParams(location.search) const queryParams = new URLSearchParams(location.search)
const paramHostId = queryParams.get(recordingsQuery.hostId) const paramHostId = queryParams.get(recordingsPageQuery.hostId)
const paramCameraId = queryParams.get(recordingsQuery.cameraId); const paramCameraId = queryParams.get(recordingsPageQuery.cameraId);
const paramDate = queryParams.get(recordingsQuery.date); const paramStartDay = queryParams.get(recordingsPageQuery.startDay);
const paramTime = queryParams.get(recordingsQuery.hour); const paramEndDay = queryParams.get(recordingsPageQuery.endDay);
const paramTime = queryParams.get(recordingsPageQuery.hour);
const [hostId, setHostId] = useState<string>('') const [hostId, setHostId] = useState<string>('')
const [cameraId, setCameraId] = useState<string>('') const [cameraId, setCameraId] = useState<string>('')
const [period, setPeriod] = useState<[Date | null, Date | null]>([null, null])
const [firstRender, setFirstRender] = useState(false)
useEffect(() => { useEffect(() => {
sideBarsStore.rightVisible = true sideBarsStore.rightVisible = true
@ -39,15 +48,21 @@ const RecordingsPage = observer(() => {
) )
if (paramHostId) recStore.hostIdParam = paramHostId if (paramHostId) recStore.hostIdParam = paramHostId
if (paramCameraId) recStore.cameraIdParam = paramCameraId if (paramCameraId) recStore.cameraIdParam = paramCameraId
if (paramStartDay && paramEndDay) {
const parsedStartDay = parseQueryDateToDate(paramStartDay)
const parsedEndDay = parseQueryDateToDate(paramEndDay)
recStore.selectedRange = [parsedStartDay, parsedEndDay]
}
setFirstRender(true)
return () => sideBarsStore.setRightChildren(null) return () => sideBarsStore.setRightChildren(null)
}, []) }, [])
useEffect(() => { useEffect(() => {
setHostId(recStore.selectedHost?.id || '') setHostId(recStore.selectedHost?.id || '')
if (recStore.selectedHost) { if (recStore.selectedHost) {
queryParams.set(recordingsQuery.hostId, recStore.selectedHost.id) queryParams.set(recordingsPageQuery.hostId, recStore.selectedHost.id)
} else { } else {
queryParams.delete(recordingsQuery.hostId) queryParams.delete(recordingsPageQuery.hostId)
} }
navigate({ pathname: location.pathname, search: queryParams.toString() }); navigate({ pathname: location.pathname, search: queryParams.toString() });
}, [recStore.selectedHost]) }, [recStore.selectedHost])
@ -55,26 +70,61 @@ const RecordingsPage = observer(() => {
useEffect(() => { useEffect(() => {
setCameraId(recStore.selectedCamera?.id || '') setCameraId(recStore.selectedCamera?.id || '')
if (recStore.selectedCamera) { if (recStore.selectedCamera) {
queryParams.set(recordingsQuery.cameraId, recStore.selectedCamera?.id) queryParams.set(recordingsPageQuery.cameraId, recStore.selectedCamera?.id)
} else { } else {
console.log('delete recordingsQuery.cameraId') queryParams.delete(recordingsPageQuery.cameraId)
queryParams.delete(recordingsQuery.cameraId)
} }
navigate({ pathname: location.pathname, search: queryParams.toString() }); navigate({ pathname: location.pathname, search: queryParams.toString() });
}, [recStore.selectedCamera]) }, [recStore.selectedCamera])
if (cameraId) { useEffect(() => {
return <SelectedCameraList cameraId={cameraId} /> setPeriod(recStore.selectedRange)
const [startDay, endDay] = recStore.selectedRange
if (startDay && endDay) {
const startQuery = dateToQueryString(startDay)
const endQuery = dateToQueryString(endDay)
queryParams.set(recordingsPageQuery.startDay, startQuery)
queryParams.set(recordingsPageQuery.endDay, endQuery)
} else {
queryParams.delete(recordingsPageQuery.startDay)
queryParams.delete(recordingsPageQuery.endDay)
}
navigate({ pathname: location.pathname, search: queryParams.toString() });
}, [recStore.selectedRange])
console.log('RecordingsPage rendered')
if (!firstRender) return <CenterLoader />
const [startDay, endDay] = period
if (startDay && endDay) {
if (startDay.getDate() === endDay.getDate()) { // if select only one day
return <SelecteDayList day={startDay} />
}
} }
if (hostId) { if (cameraId && paramCameraId) {
// console.log('cameraId', cameraId)
// console.log('paramCameraId', paramCameraId)
if ((startDay && endDay) || (!startDay && !endDay)) {
return <SelectedCameraList />
// return <SelectedCameraList cameraId={cameraId} />
}
}
if (hostId && paramHostId && !cameraId) {
return <SelectedHostList hostId={hostId} /> return <SelectedHostList hostId={hostId} />
} }
console.log('RecordingsPage rendered')
return ( return (
<Flex w='100%' h='100%' direction='column' justify='center' align='center'> <Flex w='100%' h='100%' direction='column' justify='center' align='center'>
{!hostId ?
<Text size='xl'>Please select host</Text> <Text size='xl'>Please select host</Text>
: <></>}
{hostId && !(startDay && endDay) ?
<Text size='xl'>Please select date</Text>
: <></>
}
</Flex> </Flex>
) )
}) })

View File

@ -6,11 +6,11 @@ import { useNavigate } from 'react-router-dom';
import { ExclamationCogWheel } from '../shared/components/svg/ExclamationCogWheel'; import { ExclamationCogWheel } from '../shared/components/svg/ExclamationCogWheel';
import { Context } from '..'; import { Context } from '..';
interface RetryErrorProps { interface RetryErrorPageProps {
onRetry?: () => void onRetry?: () => void
} }
const RetryError = ({ onRetry }: RetryErrorProps) => { const RetryErrorPage = ({ onRetry }: RetryErrorPageProps) => {
const navigate = useNavigate() const navigate = useNavigate()
const { sideBarsStore } = useContext(Context) const { sideBarsStore } = useContext(Context)
@ -48,4 +48,4 @@ const RetryError = ({ onRetry }: RetryErrorProps) => {
); );
}; };
export default RetryError; export default RetryErrorPage;

View File

@ -6,7 +6,7 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import RetryError from './RetryError'; import RetryErrorPage from './RetryErrorPage';
import { Button, Flex, Space } from '@mantine/core'; import { Button, Flex, Space } from '@mantine/core';
import { FloatingLabelInput } from '../shared/components/FloatingLabelInput'; import { FloatingLabelInput } from '../shared/components/FloatingLabelInput';
import { strings } from '../shared/strings/strings'; import { strings } from '../shared/strings/strings';
@ -83,7 +83,7 @@ const SettingsPage = () => {
if (configPending) return <CenterLoader /> if (configPending) return <CenterLoader />
if (configError) return <RetryError onRetry={refetch} /> if (configError) return <RetryErrorPage onRetry={refetch} />
return ( return (
<Flex h='100%'> <Flex h='100%'>

View File

@ -1,30 +0,0 @@
import React, { Fragment, useContext, useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import JSMpegPlayer from '../shared/components/frigate/JSMpegPlayer';
import { frigateApi } from '../services/frigate.proxy/frigate.api';
import { Flex } from '@mantine/core';
import AutoUpdatingCameraImage from '../shared/components/frigate/AutoUpdatingCameraImage';
const Test = observer(() => {
// const test = {
// camera: 'Buhgalteria',
// host: 'localhost:5000',
// width: 800,
// height: 600,
// url : function() { return frigateApi.cameraWsURL(this.host, this.camera)},
// }
// return (
// <Flex w='100%' h='100%'>
// <JSMpegPlayer wsUrl={test.url()} camera={test.camera} width={test.width} height={test.height} />
// </Flex>
// );
return (
<Flex w='100%' h='100%'>
</Flex>
);
})
export default Test;

18
src/pages/TestItem.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Paper } from '@mantine/core';
import { useIntersection } from '@mantine/hooks';
import React from 'react';
const TestItem = () => {
const { ref, entry } = useIntersection({threshold: 0.1,})
return (<Paper
ref={ref}
m='0.2rem' w='10rem' h='10rem'
sx={(theme) => ({
backgroundColor: entry?.isIntersecting ? theme.colors.green[9] : theme.colors.red[9],
})}
/>)
};
export default TestItem;

59
src/pages/TestPage.tsx Normal file
View File

@ -0,0 +1,59 @@
import React, { Fragment, useContext, useEffect, useRef, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { Button, Flex, Grid, Group, Indicator, Paper, Skeleton } from '@mantine/core';
import RetryError from '../shared/components/RetryError';
import { DatePickerInput } from '@mantine/dates';
import HeadSearch from '../shared/components/HeadSearch';
import ViewSelector from '../shared/components/ViewSelector';
import { useIntersection } from '@mantine/hooks';
import TestItem from './TestItem';
const Test = observer(() => {
const [value, setValue] = useState<[Date | null, Date | null]>([null, null])
useEffect(() => {
console.log('value', value)
}, [value])
const handleClick = () => {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
setValue([startOfDay, startOfDay])
}
const cards = (qty: number) => {
let items = []
for (let i = 0; i < qty; i++) {
items.push(<TestItem key={i} />)
}
return items
}
return (
<Flex direction='column' h='100%' >
<Flex justify='space-between' align='center' w='100%'>
<Group
w='25%'
>
</Group>
<Group
w='50%'
style={{
justifyContent: 'center',
}}
><HeadSearch /></Group>
<Group
w='25%'
position="right">
</Group>
</Flex>
<Flex justify='center' h='100%' direction='column' >
<Grid mt='sm' justify="center" mb='sm' align='stretch'>
{cards(60)}
</Grid>
</Flex>
</Flex>
);
})
export default Test;

View File

@ -1,8 +1,8 @@
import {JSX} from "react"; import {JSX} from "react";
import Test from "../pages/Test" import Test from "../pages/TestPage"
import MainBody from "../pages/MainBody"; import MainPage from "../pages/MainPage";
import {routesPath} from "./routes.path"; import {routesPath} from "./routes.path";
import RetryError from "../pages/RetryError"; import RetryErrorPage from "../pages/RetryErrorPage";
import Forbidden from "../pages/403"; import Forbidden from "../pages/403";
import NotFound from "../pages/404"; import NotFound from "../pages/404";
import SettingsPage from "../pages/SettingsPage"; import SettingsPage from "../pages/SettingsPage";
@ -53,11 +53,11 @@ export const routes: IRoute[] = [
}, },
{ {
path: routesPath.MAIN_PATH, path: routesPath.MAIN_PATH,
component: <MainBody />, component: <MainPage />,
}, },
{ {
path: routesPath.RETRY_ERROR_PATH, path: routesPath.RETRY_ERROR_PATH,
component: <RetryError />, component: <RetryErrorPage />,
}, },
{ {
path: routesPath.FORBIDDEN_ERROR_PATH, path: routesPath.FORBIDDEN_ERROR_PATH,

View File

@ -1,12 +1,13 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { CameraConfig } from "../../../types/frigateConfig"; import { CameraConfig } from "../../types/frigateConfig";
import { AspectRatio, Flex, createStyles, Text } from "@mantine/core"; import { Flex, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import CenterLoader from "../CenterLoader"; import CenterLoader from "./CenterLoader";
import axios from "axios"; import { frigateApi, proxyApi } from "../../services/frigate.proxy/frigate.api";
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api"; import { useIntersection } from "@mantine/hooks";
import CogwheelLoader from "./loaders/CogwheelLoader";
interface CameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface AutoUpdatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string; className?: string;
cameraConfig?: CameraConfig; cameraConfig?: CameraConfig;
onload?: () => void; onload?: () => void;
@ -18,26 +19,29 @@ const AutoUpdatedImage = ({
imageUrl, imageUrl,
enabled, enabled,
...rest ...rest
}: CameraImageProps) => { }: AutoUpdatedImageProps) => {
const { ref, entry } = useIntersection({threshold: 0.1,})
const isVisible = entry?.isIntersecting
const { data: imageBlob, refetch, isPending, isError } = useQuery({ const { data: imageBlob, refetch, isPending, isError } = useQuery({
queryKey: ['image', imageUrl], queryKey: ['image', imageUrl],
queryFn: () => proxyApi.getImageFrigate(imageUrl), queryFn: () => proxyApi.getImageFrigate(imageUrl),
staleTime: 60 * 1000, staleTime: 60 * 1000,
gcTime: Infinity, gcTime: Infinity,
refetchInterval: 60 * 1000, refetchInterval: isVisible ? 30 * 1000 : undefined,
}); });
useEffect(() => { useEffect(() => {
if (isVisible) {
console.log('imageUrl is visible')
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
refetch(); refetch();
}, 60 * 1000); }, 60 * 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [refetch]); }
}, [refetch, isVisible]);
if (isPending) return <CogwheelLoader />
if (isPending) return <CenterLoader />
if (isError) return ( if (isError) return (
<Flex direction="column" justify="center" h="100%"> <Flex direction="column" justify="center" h="100%">
@ -51,7 +55,7 @@ const AutoUpdatedImage = ({
return ( return (
<> <>
{enabled ? <img src={image} alt="Dynamic Content" {...rest}/> {enabled ? <img ref={ref} src={image} alt="Dynamic Content" {...rest}/>
: :
<Flex direction="column" justify="center" h="100%"> <Flex direction="column" justify="center" h="100%">
<Text align="center">Camera is disabled in config, no stream or snapshot available!</Text> <Text align="center">Camera is disabled in config, no stream or snapshot available!</Text>

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { CameraConfig } from '../../types/frigateConfig'; import { CameraConfig } from '../../types/frigateConfig';
import { AspectRatio, Button, Card, Flex, Grid, Group, Space, Text, createStyles, useMantineTheme } from '@mantine/core'; import { AspectRatio, Button, Card, Flex, Grid, Group, Space, Text, createStyles, useMantineTheme } from '@mantine/core';
import AutoUpdatingCameraImage from './frigate/AutoUpdatingCameraImage';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { routesPath } from '../../router/routes.path'; import { routesPath } from '../../router/routes.path';
import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema'; import { GetCameraWHostWConfig, GetFrigateHost } from '../../services/frigate.proxy/frigate.schema';
import { frigateApi, mapHostToHostname, proxyApi } from '../../services/frigate.proxy/frigate.api'; import { frigateApi, mapHostToHostname, proxyApi } from '../../services/frigate.proxy/frigate.api';
import AutoUpdatedImage from './frigate/CameraImage'; import AutoUpdatedImage from './AutoUpdatedImage';
import { recordingsPageQuery } from '../../pages/RecordingsPage';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
@ -50,7 +50,8 @@ const CameraCard = ({
navigate(url) navigate(url)
} }
const handleOpenRecordings = () => { const handleOpenRecordings = () => {
throw Error('Not yet implemented') const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost?.id}&${recordingsPageQuery.cameraId}=${camera.id}`
navigate(url)
} }
return ( return (
<Grid.Col md={6} lg={3} p='0.2rem'> <Grid.Col md={6} lg={3} p='0.2rem'>

View File

@ -7,7 +7,7 @@ import { Modal, createStyles, getStylesRef, rem, Text, Box, Flex, Grid, Divider,
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
import { dimensions } from '../dimensions/dimensions'; import { dimensions } from '../dimensions/dimensions';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import KomponentLoader from './CogwheelLoader'; import KomponentLoader from './loaders/CogwheelLoader';
import ProductParameter from './ProductParameter'; import ProductParameter from './ProductParameter';
import { productString } from '../strings/product.strings'; import { productString } from '../strings/product.strings';
import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react'; import { IconArrowBadgeLeft, IconArrowBadgeRight } from '@tabler/icons-react';

View File

@ -0,0 +1,25 @@
import { Center, Text, ActionIcon } from '@mantine/core';
import { IconRotateClockwise } from '@tabler/icons-react';
import React from 'react';
interface RetryErrorProps {
onRetry?: () => void
}
const RetryError = ({
onRetry
}: RetryErrorProps) => {
const handleClick = () => {
if (onRetry) onRetry()
}
return (
<Center>
<Text mr='md'>Loading error</Text>
<ActionIcon color="blue" size="md" radius="md" variant="filled">
<IconRotateClockwise onClick={handleClick} />
</ActionIcon>
</Center>
);
};
export default RetryError;

View File

@ -1,4 +1,4 @@
import { Accordion, Center, Text } from '@mantine/core'; import { Accordion, Center, Loader, Text } from '@mantine/core';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { GetCameraWHostWConfig, GetFrigateHost } from '../../../services/frigate.proxy/frigate.schema'; import { GetCameraWHostWConfig, GetFrigateHost } from '../../../services/frigate.proxy/frigate.schema';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -6,20 +6,23 @@ import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services
import DayAccordion from './DayAccordion'; import DayAccordion from './DayAccordion';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Context } from '../../..'; import { Context } from '../../..';
import { getResolvedTimeZone } from '../frigate/dateUtil'; import { getResolvedTimeZone, parseQueryDateToDate } from '../../utils/dateUtil';
import RetryError from '../RetryError';
import { strings } from '../../strings/strings';
import { RecordSummary } from '../../../types/record';
interface CameraAccordionProps { interface CameraAccordionProps {
camera: GetCameraWHostWConfig, camera: GetCameraWHostWConfig,
host: GetFrigateHost host: GetFrigateHost
} }
const CameraAccordion = observer(({ const CameraAccordion = ({
camera, camera,
host host
}: CameraAccordionProps) => { }: CameraAccordionProps) => {
const { recordingsStore: recStore } = useContext(Context) const { recordingsStore: recStore } = useContext(Context)
const { data, isPending, isError } = useQuery({ const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id], queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id],
queryFn: () => { queryFn: () => {
if (camera && host) { if (camera && host) {
@ -44,29 +47,46 @@ const CameraAccordion = observer(({
setOpenedDay(value) setOpenedDay(value)
} }
if (isPending) return <Center><Text>Loading...</Text></Center> if (isPending) return <Center><Loader /></Center>
if (isError) return <Center><Text>Loading error</Text></Center> if (isError) return <RetryError onRetry={refetch} />
if (!data || !camera) return null if (!data || !camera) return null
const recodItem = (record: RecordSummary) => (
const days = data.slice(0, 2).map(rec => ( <Accordion.Item key={record.day} value={record.day}>
<Accordion.Item key={rec.day} value={rec.day}> <Accordion.Control key={record.day + 'control'}>{strings.day}: {record.day}</Accordion.Control>
<Accordion.Control key={rec.day + 'control'}>{rec.day}</Accordion.Control> <Accordion.Panel key={record.day + 'panel'}>
<Accordion.Panel key={rec.day + 'panel'}> <DayAccordion key={record.day + 'day'} recordSummary={record} />
<DayAccordion key={rec.day + 'day'} recordSummary={rec} />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
)
)) const days = () => {
const [startDate, endDate] = recStore.selectedRange
if (startDate && endDate) {
return data
.filter(rec => {
const parsedRecDate = parseQueryDateToDate(rec.day)
if (parsedRecDate) {
return parsedRecDate >= startDate && parsedRecDate <= endDate
}
return false
})
.map(rec => recodItem(rec))
}
if ((startDate && endDate) || (!startDate && !endDate)) {
return data.map(rec => recodItem(rec))
}
return []
}
console.log('CameraAccordion rendered') console.log('CameraAccordion rendered')
return ( return (
<Accordion variant='separated' radius="md" w='100%' onChange={handleClick}> <Accordion variant='separated' radius="md" w='100%' onChange={handleClick}>
{days} {days()}
</Accordion> </Accordion>
) )
}) }
export default CameraAccordion; export default observer(CameraAccordion);

View File

@ -1,25 +1,23 @@
import { Accordion, Center, Flex, Group, Text } from '@mantine/core'; import { Accordion, Center, Group, Text } from '@mantine/core';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { RecordHour, RecordSummary, Recording } from '../../../types/record'; import { RecordSummary } from '../../../types/record';
import Button from '../frigate/Button';
import { IconPlayerPause, IconPlayerPlay, IconPlayerStop } from '@tabler/icons-react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import PlayControl from './PlayControl'; import PlayControl from './PlayControl';
import { frigateApi, proxyApi } from '../../../services/frigate.proxy/frigate.api'; import { proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { Context } from '../../..'; import { Context } from '../../..';
import VideoPlayer from '../frigate/VideoPlayer'; import VideoPlayer from '../players/VideoPlayer';
import { getResolvedTimeZone } from '../frigate/dateUtil'; import { getResolvedTimeZone } from '../../utils/dateUtil';
import EventsAccordion from './EventsAccordion';
import DayEventsAccordion from './DayEventsAccordion'; import DayEventsAccordion from './DayEventsAccordion';
import { strings } from '../../strings/strings';
interface RecordingAccordionProps { interface RecordingAccordionProps {
recordSummary?: RecordSummary recordSummary?: RecordSummary
} }
const DayAccordion = observer(({ const DayAccordion = ({
recordSummary recordSummary
}: RecordingAccordionProps) => { }: RecordingAccordionProps) => {
const { recordingsStore } = useContext(Context) const { recordingsStore: recStore } = useContext(Context)
const [openVideoPlayer, setOpenVideoPlayer] = useState<string>() const [openVideoPlayer, setOpenVideoPlayer] = useState<string>()
const [openedValue, setOpenedValue] = useState<string>() const [openedValue, setOpenedValue] = useState<string>()
const [playerUrl, setPlayerUrl] = useState<string>() const [playerUrl, setPlayerUrl] = useState<string>()
@ -28,11 +26,11 @@ const DayAccordion = observer(({
if (openVideoPlayer) { if (openVideoPlayer) {
console.log('openVideoPlayer', openVideoPlayer) console.log('openVideoPlayer', openVideoPlayer)
if (openVideoPlayer) { if (openVideoPlayer) {
recordingsStore.recordToPlay.day = recordSummary?.day recStore.recordToPlay.day = recordSummary?.day
recordingsStore.recordToPlay.hour = openVideoPlayer recStore.recordToPlay.hour = openVideoPlayer
recordingsStore.recordToPlay.timezone = getResolvedTimeZone().replace('/', ',') recStore.recordToPlay.timezone = getResolvedTimeZone().replace('/', ',')
const parsed = recordingsStore.getFullRecordForPlay(recordingsStore.recordToPlay) const parsed = recStore.getFullRecordForPlay(recStore.recordToPlay)
console.log('recordingsStore.playedRecord: ', recordingsStore.recordToPlay) console.log('recordingsStore.playedRecord: ', recStore.recordToPlay)
if (parsed.success) { if (parsed.success) {
const url = proxyApi.recordingURL( const url = proxyApi.recordingURL(
parsed.data.hostName, parsed.data.hostName,
@ -50,10 +48,10 @@ const DayAccordion = observer(({
} }
}, [openVideoPlayer]) }, [openVideoPlayer])
if (!recordSummary || recordSummary.hours.length < 1) return (<Text>Not have record at that day</Text>) if (!recordSummary ) return <Text>Not have record at that day</Text>
if (recordSummary.hours.length < 1) return <Text>Not have record at that day</Text>
const handleOpenPlayer = (hour: string) => { const handleOpenPlayer = (hour: string) => {
// console.log(`openVideoPlayer day:${recordSummary.day} hour:${hour}`)
if (openVideoPlayer !== hour) { if (openVideoPlayer !== hour) {
setOpenedValue(hour) setOpenedValue(hour)
setOpenVideoPlayer(hour) setOpenVideoPlayer(hour)
@ -73,6 +71,17 @@ const DayAccordion = observer(({
console.log('DayAccordion rendered') console.log('DayAccordion rendered')
const hourLabel = (hour: string, eventsQty: number) => (
<Group>
<Text>{strings.hour}: {hour}:00</Text>
{eventsQty > 0 ?
<Text>{strings.events}: {eventsQty}</Text>
:
<Text>{strings.notHaveEvents}</Text>
}
</Group>
)
return ( return (
<Accordion <Accordion
key={recordSummary.day} key={recordSummary.day}
@ -81,23 +90,27 @@ const DayAccordion = observer(({
value={openedValue} value={openedValue}
onChange={handleClick} onChange={handleClick}
> >
{recordSummary.hours.slice(0, 5).map(hour => ( {recordSummary.hours.map(hour => (
<Accordion.Item key={hour.hour + 'Item'} value={hour.hour}> <Accordion.Item key={hour.hour + 'Item'} value={hour.hour}>
<Accordion.Control key={hour.hour + 'Control'}> <Accordion.Control key={hour.hour + 'Control'}>
<PlayControl label={`Hour ${hour.hour}`} value={hour.hour} openVideoPlayer={openVideoPlayer} onClick={handleOpenPlayer} /> <PlayControl
label={hourLabel(hour.hour, hour.events)}
value={hour.hour}
openVideoPlayer={openVideoPlayer}
onClick={handleOpenPlayer} />
</Accordion.Control> </Accordion.Control>
<Accordion.Panel key={hour.hour + 'Panel'}> <Accordion.Panel key={hour.hour + 'Panel'}>
{openVideoPlayer === hour.hour && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>} {openVideoPlayer === hour.hour && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
{hour.events > 0 ? {hour.events > 0 ?
<DayEventsAccordion day={recordSummary.day} hour={hour.hour} qty={hour.events} /> <DayEventsAccordion day={recordSummary.day} hour={hour.hour} qty={hour.events} />
: :
<Center><Text>Not have events</Text></Center> <Center><Text>{strings.notHaveEvents}</Text></Center>
} }
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
))} ))}
</Accordion> </Accordion>
) )
}) }
export default DayAccordion; export default observer(DayAccordion);

View File

@ -1,5 +1,6 @@
import { Accordion, Text } from '@mantine/core'; import { Accordion, Text } from '@mantine/core';
import React, { Suspense, lazy, useState } from 'react'; import React, { Suspense, lazy, useState } from 'react';
import { strings } from '../../strings/strings';
const EventsAccordion = lazy(() => import('./EventsAccordion')) const EventsAccordion = lazy(() => import('./EventsAccordion'))
interface DayEventsAccordionProps { interface DayEventsAccordionProps {
@ -21,7 +22,7 @@ const DayEventsAccordion = ({
return ( return (
<Accordion onChange={handleClick}> <Accordion onChange={handleClick}>
<Accordion.Item value={hour}> <Accordion.Item value={hour}>
<Accordion.Control><Text>Events {qty}</Text></Accordion.Control> <Accordion.Control><Text>{strings.events}: {qty}</Text></Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
{openedItem === hour ? {openedItem === hour ?
<Suspense> <Suspense>

View File

@ -1,13 +1,16 @@
import { Accordion, Center, Text } from '@mantine/core'; import { Accordion, Center, Group, Loader, Text } from '@mantine/core';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Context } from '../../..'; import { Context } from '../../..';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { frigateQueryKeys, proxyApi } from '../../../services/frigate.proxy/frigate.api'; import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema'; import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema';
import PlayControl from './PlayControl'; import PlayControl from './PlayControl';
import VideoPlayer from '../frigate/VideoPlayer'; import VideoPlayer from '../players/VideoPlayer';
import { formatUnixTimestampToDateTime, getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../frigate/dateUtil'; import { getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../../utils/dateUtil';
import RetryError from '../RetryError';
import { strings } from '../../strings/strings';
import { EventFrigate } from '../../../types/event';
/** /**
* @param day frigate format, e.g day: 2024-02-23 * @param day frigate format, e.g day: 2024-02-23
@ -31,8 +34,6 @@ interface EventsAccordionProps {
const EventsAccordion = observer(({ const EventsAccordion = observer(({
day, day,
hour, hour,
cameraName,
hostName,
// TODO labels, score // TODO labels, score
}: EventsAccordionProps) => { }: EventsAccordionProps) => {
const { recordingsStore: recStore } = useContext(Context) const { recordingsStore: recStore } = useContext(Context)
@ -40,17 +41,17 @@ const EventsAccordion = observer(({
const [openedValue, setOpenedValue] = useState<string>() const [openedValue, setOpenedValue] = useState<string>()
const [playerUrl, setPlayerUrl] = useState<string>() const [playerUrl, setPlayerUrl] = useState<string>()
const inHostName = hostName || recStore.recordToPlay.hostName const inHost = recStore.selectedHost
const inCameraName = cameraName || recStore.recordToPlay.cameraName const inCamera = recStore.selectedCamera
const isRequiredParams = inCameraName && inHostName const isRequiredParams = inHost && inCamera
const { data, isPending, isError, refetch } = useQuery({ const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getEvents, day, hour, inCameraName, inHostName], queryKey: [frigateQueryKeys.getEvents, inHost, inCamera, day, hour],
queryFn: () => { queryFn: () => {
if (!isRequiredParams) return null if (!isRequiredParams) return null
const [startTime, endTime] = getUnixTime(day, hour) const [startTime, endTime] = getUnixTime(day, hour)
const parsed = getEventsQuerySchema.safeParse({ const parsed = getEventsQuerySchema.safeParse({
hostName: inHostName, hostName: mapHostToHostname(inHost),
camerasName: [inCameraName], camerasName: [inCamera.name],
after: startTime, after: startTime,
before: endTime, before: endTime,
hasClip: true, hasClip: true,
@ -78,8 +79,8 @@ const EventsAccordion = observer(({
useEffect(() => { useEffect(() => {
if (openVideoPlayer) { if (openVideoPlayer) {
console.log('openVideoPlayer', openVideoPlayer) console.log('openVideoPlayer', openVideoPlayer)
if (openVideoPlayer && inHostName) { if (openVideoPlayer && inHost) {
const url = proxyApi.eventURL(inHostName, openVideoPlayer) const url = proxyApi.eventURL(mapHostToHostname(inHost), openVideoPlayer)
console.log('GET EVENT URL: ', url) console.log('GET EVENT URL: ', url)
setPlayerUrl(url) setPlayerUrl(url)
} }
@ -88,8 +89,8 @@ const EventsAccordion = observer(({
} }
}, [openVideoPlayer]) }, [openVideoPlayer])
if (isPending) return <Center><Text>Loading...</Text></Center> if (isPending) return <Center><Loader /></Center>
if (isError) return <Center><Text>Loading error</Text></Center> if (isError) return <RetryError onRetry={refetch} />
if (!data || data.length < 1) return <Center><Text>Not have events at that period</Text></Center> if (!data || data.length < 1) return <Center><Text>Not have events at that period</Text></Center>
const handleOpenPlayer = (eventId: string) => { const handleOpenPlayer = (eventId: string) => {
@ -111,6 +112,20 @@ const EventsAccordion = observer(({
setOpenVideoPlayer(undefined) setOpenVideoPlayer(undefined)
} }
const eventLabel = (event: EventFrigate) => {
const time = unixTimeToDate(event.start_time)
const duration = getDurationFromTimestamps(event.start_time, event.end_time)
return (
<Group>
<Text>{strings.player.object}: {event.label}</Text>
<Text>{time}</Text>
{duration ?
<Text>{duration}</Text>
: <></>}
</Group>
)
}
return ( return (
<Accordion <Accordion
variant='separated' variant='separated'
@ -118,21 +133,25 @@ const EventsAccordion = observer(({
value={openedValue} value={openedValue}
onChange={handleClick} onChange={handleClick}
> >
{data.slice(0, 5).map(event => ( {data.map(event => (
<Accordion.Item key={event.id + 'Item'} value={event.id}> <Accordion.Item key={event.id + 'Item'} value={event.id}>
<Accordion.Control key={event.id + 'Control'}> <Accordion.Control key={event.id + 'Control'}>
<PlayControl <PlayControl
label={unixTimeToDate(event.start_time)} label={eventLabel(event)}
value={event.id} value={event.id}
openVideoPlayer={openVideoPlayer} openVideoPlayer={openVideoPlayer}
onClick={handleOpenPlayer} /> onClick={handleOpenPlayer} />
</Accordion.Control> </Accordion.Control>
<Accordion.Panel key={event.id + 'Panel'}> <Accordion.Panel key={event.id + 'Panel'}>
{openVideoPlayer === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>} {openVideoPlayer === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
<Text>Camera: {event.camera}</Text> <Group mt='1rem'>
<Text>Label: {event.label}</Text> <Text>{strings.camera}: {event.camera}</Text>
<Text>Start: {unixTimeToDate(event.start_time)}</Text> <Text>{strings.player.object}: {event.label}</Text>
<Text>Duration: {getDurationFromTimestamps(event.start_time, event.end_time)}</Text> </Group>
<Group>
<Text>{strings.player.startTime}: {unixTimeToDate(event.start_time)}</Text>
<Text>{strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)}</Text>
</Group>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
))} ))}

View File

@ -1,9 +1,33 @@
import { Flex, Group, Text } from '@mantine/core';
import { IconPlayerPlay, IconPlayerStop } from '@tabler/icons-react';
import React from 'react'; import React from 'react';
import { Flex, Group, Text, createStyles } from '@mantine/core';
import { IconPlayerPlay, IconPlayerPlayFilled, IconPlayerStop, IconPlayerStopFilled } from '@tabler/icons-react';
import { strings } from '../../strings/strings';
const useStyles = createStyles((theme) => ({
group: {
backgroundColor: theme.colors.blue[7],
borderRadius: '1rem',
paddingLeft: '0.5rem',
paddingRight: '0.3rem',
'&:hover': {
backgroundColor: theme.fn.darken(theme.colors.blue[7], 0.2),
},
},
text: {
color: theme.white,
fontWeight: 'normal'
},
iconStop: {
color: theme.colors.red[5]
},
iconPlay: {
color: theme.colors.green[5]
}
}))
interface PlayControlProps { interface PlayControlProps {
label: string, label: string | JSX.Element,
value: string, value: string,
openVideoPlayer?: string, openVideoPlayer?: string,
onClick?: (value: string) => void onClick?: (value: string) => void
@ -15,26 +39,38 @@ const PlayControl = ({
openVideoPlayer, openVideoPlayer,
onClick onClick
}: PlayControlProps) => { }: PlayControlProps) => {
const { classes } = useStyles();
const handleClick = (value: string) => { const handleClick = (value: string) => {
if (onClick) onClick(value) if (onClick) onClick(value)
} }
return ( return (
<Flex justify='space-between'> <Flex justify='space-between'>
{label} {label}
<Group> <Group className={classes.group}
<Text onClick={(event) => { onClick={(event) => {
event.stopPropagation()
handleClick(value)
}}
>
<Text className={classes.text}
onClick={(event) => {
event.stopPropagation() event.stopPropagation()
handleClick(value) handleClick(value)
}}> }}>
{openVideoPlayer === value ? 'Stop Video' : 'Play Video'} {openVideoPlayer === value ? strings.player.stopVideo : strings.player.startVideo}
</Text> </Text>
{openVideoPlayer === value ? {openVideoPlayer === value ?
<IconPlayerStop onClick={(event) => { <IconPlayerStopFilled
className={classes.iconStop}
onClick={(event) => {
event.stopPropagation() event.stopPropagation()
handleClick(value) handleClick(value)
}} /> }} />
: :
<IconPlayerPlay onClick={(event) => { <IconPlayerPlayFilled
className={classes.iconPlay}
onClick={(event) => {
event.stopPropagation() event.stopPropagation()
handleClick(value) handleClick(value)
}} /> }} />

View File

@ -1,11 +0,0 @@
import React from 'react';
const TestAccordion = ({ camera }: { camera: any }) => {
return (
<div>
TEST ACCORDION {camera.name}
</div>
);
};
export default TestAccordion;

View File

@ -3,9 +3,11 @@ import React, { useContext, useEffect } from 'react';
import { Context } from '../../..'; import { Context } from '../../..';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api'; import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api';
import CogwheelLoader from '../CogwheelLoader'; import CogwheelLoader from '../loaders/CogwheelLoader';
import { Center, Text } from '@mantine/core'; import { Center, Loader, Text } from '@mantine/core';
import OneSelectFilter, { OneSelectItem } from './OneSelectFilter'; import OneSelectFilter, { OneSelectItem } from './OneSelectFilter';
import { strings } from '../../strings/strings';
import RetryError from '../RetryError';
interface CameraSelectFilterProps { interface CameraSelectFilterProps {
selectedHostId: string, selectedHostId: string,
@ -16,7 +18,7 @@ const CameraSelectFilter = ({
}: CameraSelectFilterProps) => { }: CameraSelectFilterProps) => {
const { recordingsStore: recStore } = useContext(Context) const { recordingsStore: recStore } = useContext(Context)
const { data, isError, isPending, isSuccess } = useQuery({ const { data, isError, isPending, isSuccess, refetch } = useQuery({
queryKey: [frigateQueryKeys.getFrigateHost, selectedHostId], queryKey: [frigateQueryKeys.getFrigateHost, selectedHostId],
queryFn: () => frigateApi.getHost(selectedHostId) queryFn: () => frigateApi.getHost(selectedHostId)
}) })
@ -30,8 +32,8 @@ const CameraSelectFilter = ({
} }
}, [isSuccess]) }, [isSuccess])
if (isPending) return <CogwheelLoader /> if (isPending) return <Loader />
if (isError) return <Center><Text>Loading error!</Text></Center> if (isError) return <RetryError onRetry={refetch}/>
if (!data) return null if (!data) return null
const camerasItems: OneSelectItem[] = data.cameras.map(camera => ({ value: camera.id, label: camera.name })) const camerasItems: OneSelectItem[] = data.cameras.map(camera => ({ value: camera.id, label: camera.name }))
@ -51,7 +53,7 @@ const CameraSelectFilter = ({
return ( return (
<OneSelectFilter <OneSelectFilter
id='frigate-cameras' id='frigate-cameras'
label='Select camera:' label={strings.selectCamera}
spaceBetween='1rem' spaceBetween='1rem'
value={recStore.selectedCamera?.id || ''} value={recStore.selectedCamera?.id || ''}
defaultValue={recStore.selectedCamera?.id || ''} defaultValue={recStore.selectedCamera?.id || ''}

View File

@ -0,0 +1,59 @@
import { DatePickerInput } from '@mantine/dates';
import { observer } from 'mobx-react-lite';
import React, { useContext } from 'react';
import { strings } from '../../strings/strings';
import { Box, Flex, Indicator, Text } from '@mantine/core';
import CloseWithTooltip from '../CloseWithTooltip';
import { Context } from '../../..';
interface DateRangeSelectFilterProps {
}
const DateRangeSelectFilter = ({
}: DateRangeSelectFilterProps) => {
const { recordingsStore: recStore } = useContext(Context)
const handlePick = (value: [Date | null, Date | null]) => {
console.log('handlePick',value)
recStore.selectedRange = value
}
console.log('DateRangeSelectFilter rendered')
return (
<Box>
<Flex
mt='1rem'
justify='space-between'>
<Text>{strings.selectRange}</Text>
</Flex>
<DatePickerInput
w='100%'
mt='1rem'
clearable
allowSingleDateInRange
valueFormat="YYYY-MM-DD"
type="range"
placeholder={strings.selectRange}
mx="auto"
maw={400}
value={recStore.selectedRange}
onChange={handlePick}
renderDay={(date) => {
const day = date.getDate();
const now = new Date().getDate()
return (
<Indicator size={6} color="red" offset={-5} disabled={day !== now}>
<div>{day}</div>
</Indicator>
);
}}
/>
</Box>
);
};
export default observer(DateRangeSelectFilter);

View File

@ -0,0 +1,63 @@
import { Center, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { observer } from 'mobx-react-lite';
import React, { useContext, useEffect } from 'react';
import { Context } from '../../..';
import { frigateQueryKeys, frigateApi } from '../../../services/frigate.proxy/frigate.api';
import { strings } from '../../strings/strings';
import CogwheelLoader from '../loaders/CogwheelLoader';
import OneSelectFilter, { OneSelectItem } from './OneSelectFilter';
import RetryError from '../RetryError';
const HostSelectFilter = () => {
const { recordingsStore: recStore } = useContext(Context)
const { data: hosts, isError, isPending, isSuccess, refetch } = useQuery({
queryKey: [frigateQueryKeys.getFrigateHosts],
queryFn: frigateApi.getHosts
})
useEffect(() => {
if (!hosts) return
if (recStore.hostIdParam) {
recStore.selectedHost = hosts.find(host => host.id === recStore.hostIdParam)
recStore.hostIdParam = undefined
}
}, [isSuccess])
if (isPending) return <CogwheelLoader />
if (isError) return <RetryError onRetry={refetch}/>
if (!hosts || hosts.length < 1) return null
const hostItems: OneSelectItem[] = hosts
.filter(host => host.enabled)
.map(host => ({ value: host.id, label: host.name }))
const handleSelect = (id: string, value: string) => {
const host = hosts?.find(host => host.id === value)
if (!host) {
recStore.selectedHost = undefined
recStore.selectedCamera = undefined
return
}
if (recStore.selectedHost?.id !== host.id) {
recStore.selectedCamera = undefined
}
recStore.selectedHost = host
}
return (
<OneSelectFilter
id='frigate-hosts'
label={strings.selectHost}
spaceBetween='1rem'
value={recStore.selectedHost?.id || ''}
defaultValue={recStore.selectedHost?.id || ''}
data={hostItems}
onChange={handleSelect}
/>
);
};
export default observer(HostSelectFilter);

View File

@ -1,97 +1,99 @@
import { useCallback, useEffect, useMemo, useState } from "react"; // import { useCallback, useEffect, useMemo, useState } from "react";
import CameraImage from "./CameraImage"; // import CameraImage from "./CameraImage";
import { CameraConfig } from "../../../types/frigateConfig"; // import { CameraConfig } from "../../../types/frigateConfig";
import { useDocumentVisibility } from "@mantine/hooks"; // import { useDocumentVisibility } from "@mantine/hooks";
import { AspectRatio, Flex } from "@mantine/core"; // import { AspectRatio, Flex } from "@mantine/core";
interface AutoUpdatingCameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { export {}
cameraConfig?: CameraConfig
searchParams?: {};
showFps?: boolean;
className?: string;
url: string
};
// TODO Delete // interface AutoUpdatingCameraImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
export default function AutoUpdatingCameraImage({ // cameraConfig?: CameraConfig
cameraConfig, // searchParams?: {};
searchParams = "", // showFps?: boolean;
showFps = true, // className?: string;
className, // url: string
url, // };
...rest
}: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState<string>("0");
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
const windowVisible = useDocumentVisibility() // // TODO Delete
// export default function AutoUpdatingCameraImage({
// cameraConfig,
// searchParams = "",
// showFps = true,
// className,
// url,
// ...rest
// }: AutoUpdatingCameraImageProps) {
// const [key, setKey] = useState(Date.now());
// const [fps, setFps] = useState<string>("0");
// const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
// const windowVisible = useDocumentVisibility()
const reloadInterval = useMemo(() => { // const reloadInterval = useMemo(() => {
if (windowVisible === "hidden") { // if (windowVisible === "hidden") {
return -1; // no reason to update the image when the window is not visible // return -1; // no reason to update the image when the window is not visible
}
// if (liveReady) {
// return 60000;
// } // }
// if (cameraActive) { // // if (liveReady) {
// return 200; // // return 60000;
// // }
// // if (cameraActive) {
// // return 200;
// // }
// return 30000;
// }, [windowVisible]);
// useEffect(() => {
// if (reloadInterval == -1) {
// return;
// } // }
return 30000; // setKey(Date.now());
}, [windowVisible]);
useEffect(() => { // return () => {
if (reloadInterval == -1) { // if (timeoutId) {
return; // clearTimeout(timeoutId);
} // setTimeoutId(undefined);
// }
// };
// }, [reloadInterval]);
setKey(Date.now()); // const handleLoad = useCallback(() => {
// if (reloadInterval == -1) {
// return;
// }
return () => { // const loadTime = Date.now() - key;
if (timeoutId) {
clearTimeout(timeoutId);
setTimeoutId(undefined);
}
};
}, [reloadInterval]);
const handleLoad = useCallback(() => { // if (showFps) {
if (reloadInterval == -1) { // setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1));
return; // }
}
const loadTime = Date.now() - key; // setTimeoutId(
// setTimeout(
// () => {
// setKey(Date.now());
// },
// loadTime > reloadInterval ? 1 : reloadInterval
// )
// );
// }, [key, setFps]);
if (showFps) { // return (
setFps((1000 / Math.max(loadTime, reloadInterval)).toFixed(1)); // // <AspectRatio ratio={1}>
} // <Flex direction='column' h='100%'>
// {/* <CameraImage
setTimeoutId( // cameraConfig={cameraConfig}
setTimeout( // onload={handleLoad}
() => { // enabled={cameraConfig?.enabled}
setKey(Date.now()); // url={url}
}, // {...rest}
loadTime > reloadInterval ? 1 : reloadInterval // /> */}
) // {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
); // </Flex>
}, [key, setFps]); // // </AspectRatio >
// );
return ( // }
// <AspectRatio ratio={1}>
<Flex direction='column' h='100%'>
{/* <CameraImage
cameraConfig={cameraConfig}
onload={handleLoad}
enabled={cameraConfig?.enabled}
url={url}
{...rest}
/> */}
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</Flex>
// </AspectRatio >
);
}

View File

@ -1,115 +1,117 @@
import Tooltip from './Tooltip'; // import Tooltip from './Tooltip';
import { Fragment, useCallback, useRef, useState } from 'react'; // import { Fragment, useCallback, useRef, useState } from 'react';
const ButtonColors = { export {}
blue: {
contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300',
outlined:
'text-blue-500 border-2 border-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
text: 'text-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
iconOnly: 'text-blue-500 hover:text-blue-200',
},
red: {
contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300',
outlined:
'text-red-500 border-2 border-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
iconOnly: 'text-red-500 hover:text-red-200',
},
yellow: {
contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300',
outlined:
'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
text: 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
iconOnly: 'text-yellow-500 hover:text-yellow-200',
},
green: {
contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300',
outlined:
'text-green-500 border-2 border-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
text: 'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
iconOnly: 'text-green-500 hover:text-green-200',
},
gray: {
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
outlined:
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
iconOnly: 'text-gray-500 hover:text-gray-200',
},
disabled: {
contained: 'bg-gray-400',
outlined:
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
iconOnly: 'text-gray-500 hover:text-gray-200',
},
black: {
contained: '',
outlined: '',
text: 'text-black dark:text-white',
iconOnly: '',
},
};
const ButtonTypes = { // const ButtonColors = {
contained: 'text-white shadow focus:shadow-xl hover:shadow-md', // blue: {
outlined: '', // contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300',
text: 'transition-opacity', // outlined:
iconOnly: 'transition-opacity', // 'text-blue-500 border-2 border-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
}; // text: 'text-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
// iconOnly: 'text-blue-500 hover:text-blue-200',
// },
// red: {
// contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300',
// outlined:
// 'text-red-500 border-2 border-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
// text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
// iconOnly: 'text-red-500 hover:text-red-200',
// },
// yellow: {
// contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300',
// outlined:
// 'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
// text: 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
// iconOnly: 'text-yellow-500 hover:text-yellow-200',
// },
// green: {
// contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300',
// outlined:
// 'text-green-500 border-2 border-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
// text: 'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
// iconOnly: 'text-green-500 hover:text-green-200',
// },
// gray: {
// contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
// outlined:
// 'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
// text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
// iconOnly: 'text-gray-500 hover:text-gray-200',
// },
// disabled: {
// contained: 'bg-gray-400',
// outlined:
// 'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
// text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
// iconOnly: 'text-gray-500 hover:text-gray-200',
// },
// black: {
// contained: '',
// outlined: '',
// text: 'text-black dark:text-white',
// iconOnly: '',
// },
// };
export default function Button({ // const ButtonTypes = {
children, // contained: 'text-white shadow focus:shadow-xl hover:shadow-md',
className = '', // outlined: '',
color = 'blue', // text: 'transition-opacity',
disabled = false, // iconOnly: 'transition-opacity',
ariaCapitalize = false, // };
href,
target,
type = 'contained',
...attrs
}) {
const [hovered, setHovered] = useState(false);
const ref = useRef();
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${ // export default function Button({
ButtonColors[disabled ? 'disabled' : color][type] // children,
} font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${ // className = '',
disabled ? 'cursor-not-allowed' : `${type == 'iconOnly' ? '' : 'focus:ring-2'} cursor-pointer` // color = 'blue',
}`; // disabled = false,
// ariaCapitalize = false,
// href,
// target,
// type = 'contained',
// ...attrs
// }) {
// const [hovered, setHovered] = useState(false);
// const ref = useRef();
if (disabled) { // let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, ''); // ButtonColors[disabled ? 'disabled' : color][type]
} // } font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
// disabled ? 'cursor-not-allowed' : `${type == 'iconOnly' ? '' : 'focus:ring-2'} cursor-pointer`
// }`;
const handleMousenter = useCallback(() => { // if (disabled) {
setHovered(true); // classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
}, []); // }
const handleMouseleave = useCallback(() => { // const handleMousenter = useCallback(() => {
setHovered(false); // setHovered(true);
}, []); // }, []);
const Element = href ? 'a' : 'div'; // const handleMouseleave = useCallback(() => {
// setHovered(false);
// }, []);
return ( // const Element = href ? 'a' : 'div';
<Fragment>
<Element // return (
role="button" // <Fragment>
aria-disabled={disabled ? 'true' : 'false'} // <Element
tabindex="0" // role="button"
className={classes} // aria-disabled={disabled ? 'true' : 'false'}
href={href} // tabindex="0"
target={target} // className={classes}
ref={ref} // href={href}
onmouseenter={handleMousenter} // target={target}
onmouseleave={handleMouseleave} // ref={ref}
{...attrs} // onmouseenter={handleMousenter}
> // onmouseleave={handleMouseleave}
{children} // {...attrs}
</Element> // >
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} capitalize={ariaCapitalize} /> : null} // {children}
</Fragment> // </Element>
); // {hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} capitalize={ariaCapitalize} /> : null}
} // </Fragment>
// );
// }

View File

@ -1,159 +1,161 @@
import { useCallback, useMemo, useState } from "react"; // import { useCallback, useMemo, useState } from "react";
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage"; // import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
import { CameraConfig } from "../../../types/frigateConfig"; // import { CameraConfig } from "../../../types/frigateConfig";
import { usePersistence } from "../../../hooks/use-persistence"; // import { usePersistence } from "../../../hooks/use-persistence";
import { Button, Switch, Text } from "@mantine/core"; // import { Button, Switch, Text } from "@mantine/core";
import { Card, CardContent, CardHeader, CardTitle } from "./card"; // import { Card, CardContent, CardHeader, CardTitle } from "./card";
import { IconSettings } from "@tabler/icons-react"; // import { IconSettings } from "@tabler/icons-react";
type Options = { [key: string]: boolean }; export {}
const emptyObject = Object.freeze({}); // type Options = { [key: string]: boolean };
type DebugCameraImageProps = { // const emptyObject = Object.freeze({});
className?: string;
cameraConfig: CameraConfig
url: string
};
export default function DebugCameraImage({ // type DebugCameraImageProps = {
className, // className?: string;
cameraConfig, // cameraConfig: CameraConfig
url, // url: string
}: DebugCameraImageProps) { // };
const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence(
`${cameraConfig?.name}-feed`,
emptyObject
);
const handleSetOption = useCallback(
(id: string, value: boolean) => {
const newOptions = { ...options, [id]: value };
setOptions(newOptions);
},
[options]
);
const searchParams = useMemo(
() =>
new URLSearchParams(
Object.keys(options).reduce((memo, key) => {
//@ts-ignore we know this is correct
memo.push([key, options[key] === true ? "1" : "0"]);
return memo;
}, [])
),
[options]
);
const handleToggleSettings = useCallback(() => {
setShowSettings(!showSettings);
}, [showSettings]);
return ( // export default function DebugCameraImage({
<div className={className}> // className,
<AutoUpdatingCameraImage // cameraConfig,
cameraConfig={cameraConfig} // url,
searchParams={searchParams} // }: DebugCameraImageProps) {
url={url} // const [showSettings, setShowSettings] = useState(false);
/> // const [options, setOptions] = usePersistence(
<Button onClick={handleToggleSettings} variant="link" size="sm"> // `${cameraConfig?.name}-feed`,
<span className="w-5 h-5"> // emptyObject
<IconSettings /> // );
</span>{" "} // const handleSetOption = useCallback(
<span>{showSettings ? "Hide" : "Show"} Options</span> // (id: string, value: boolean) => {
</Button> // const newOptions = { ...options, [id]: value };
{showSettings ? ( // setOptions(newOptions);
<Card> // },
<CardHeader> // [options]
<CardTitle>Options</CardTitle> // );
</CardHeader> // const searchParams = useMemo(
<CardContent> // () =>
<DebugSettings // new URLSearchParams(
handleSetOption={handleSetOption} // Object.keys(options).reduce((memo, key) => {
options={options} // //@ts-ignore we know this is correct
/> // memo.push([key, options[key] === true ? "1" : "0"]);
</CardContent> // return memo;
</Card> // }, [])
) : null} // ),
</div> // [options]
); // );
} // const handleToggleSettings = useCallback(() => {
// setShowSettings(!showSettings);
// }, [showSettings]);
type DebugSettingsProps = { // return (
handleSetOption: (id: string, value: boolean) => void; // <div className={className}>
options: Options; // <AutoUpdatingCameraImage
}; // cameraConfig={cameraConfig}
// searchParams={searchParams}
// url={url}
// />
// <Button onClick={handleToggleSettings} variant="link" size="sm">
// <span className="w-5 h-5">
// <IconSettings />
// </span>{" "}
// <span>{showSettings ? "Hide" : "Show"} Options</span>
// </Button>
// {showSettings ? (
// <Card>
// <CardHeader>
// <CardTitle>Options</CardTitle>
// </CardHeader>
// <CardContent>
// <DebugSettings
// handleSetOption={handleSetOption}
// options={options}
// />
// </CardContent>
// </Card>
// ) : null}
// </div>
// );
// }
function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { // type DebugSettingsProps = {
return ( // handleSetOption: (id: string, value: boolean) => void;
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> // options: Options;
<div className="flex items-center space-x-2"> // };
<Switch
id="bbox" // function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
checked={options["bbox"]} // return (
onChange={() => { }} // <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
// onCheckedChange={(isChecked) => { // <div className="flex items-center space-x-2">
// handleSetOption("bbox", isChecked); // <Switch
// }} // id="bbox"
/> // checked={options["bbox"]}
{/* <Label htmlFor="bbox">Bounding Box</Label> */} // onChange={() => { }}
<Text>Bounding Box</Text> // // onCheckedChange={(isChecked) => {
</div> // // handleSetOption("bbox", isChecked);
<div className="flex items-center space-x-2"> // // }}
<Switch // />
id="timestamp" // {/* <Label htmlFor="bbox">Bounding Box</Label> */}
checked={options["timestamp"]} // <Text>Bounding Box</Text>
// onCheckedChange={(isChecked) => { // </div>
// handleSetOption("timestamp", isChecked); // <div className="flex items-center space-x-2">
// }} // <Switch
/> // id="timestamp"
{/* <Label htmlFor="timestamp">Timestamp</Label> */} // checked={options["timestamp"]}
<Text>Timestamp</Text> // // onCheckedChange={(isChecked) => {
</div> // // handleSetOption("timestamp", isChecked);
<div className="flex items-center space-x-2"> // // }}
<Switch // />
id="zones" // {/* <Label htmlFor="timestamp">Timestamp</Label> */}
checked={options["zones"]} // <Text>Timestamp</Text>
// onCheckedChange={(isChecked) => { // </div>
// handleSetOption("zones", isChecked); // <div className="flex items-center space-x-2">
// }} // <Switch
/> // id="zones"
{/* <Label htmlFor="zones">Zones</Label> */} // checked={options["zones"]}
<Text>Zones</Text> // // onCheckedChange={(isChecked) => {
</div> // // handleSetOption("zones", isChecked);
<div className="flex items-center space-x-2"> // // }}
<Switch // />
id="mask" // {/* <Label htmlFor="zones">Zones</Label> */}
checked={options["mask"]} // <Text>Zones</Text>
// onCheckedChange={(isChecked) => { // </div>
// handleSetOption("mask", isChecked); // <div className="flex items-center space-x-2">
// }} // <Switch
/> // id="mask"
{/* <Label htmlFor="mask">Mask</Label> */} // checked={options["mask"]}
<Text>Mask</Text> // // onCheckedChange={(isChecked) => {
</div> // // handleSetOption("mask", isChecked);
<div className="flex items-center space-x-2"> // // }}
<Switch // />
id="motion" // {/* <Label htmlFor="mask">Mask</Label> */}
checked={options["motion"]} // <Text>Mask</Text>
// onCheckedChange={(isChecked) => { // </div>
// handleSetOption("motion", isChecked); // <div className="flex items-center space-x-2">
// }} // <Switch
/> // id="motion"
{/* <Label htmlFor="motion">Motion</Label> */} // checked={options["motion"]}
<Text>Motion</Text> // // onCheckedChange={(isChecked) => {
</div> // // handleSetOption("motion", isChecked);
<div className="flex items-center space-x-2"> // // }}
<Switch // />
id="regions" // {/* <Label htmlFor="motion">Motion</Label> */}
checked={options["regions"]} // <Text>Motion</Text>
// onCheckedChange={(isChecked) => { // </div>
// handleSetOption("regions", isChecked); // <div className="flex items-center space-x-2">
// }} // <Switch
/> // id="regions"
{/* <Label htmlFor="regions">Regions</Label> */} // checked={options["regions"]}
<Text>Regions</Text> // // onCheckedChange={(isChecked) => {
</div> // // handleSetOption("regions", isChecked);
</div> // // }}
); // />
} // {/* <Label htmlFor="regions">Regions</Label> */}
// <Text>Regions</Text>
// </div>
// </div>
// );
// }

View File

@ -1,35 +1,37 @@
import { h, Fragment } from 'preact'; // import { h, Fragment } from 'preact';
import { createPortal } from 'preact/compat'; // import { createPortal } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks'; // import { useState, useEffect } from 'preact/hooks';
export default function Dialog({ children, portalRootID = 'dialogs' }) { export {}
const portalRoot = portalRootID && document.getElementById(portalRootID);
const [show, setShow] = useState(false);
useEffect(() => { // export default function Dialog({ children, portalRootID = 'dialogs' }) {
window.requestAnimationFrame(() => { // const portalRoot = portalRootID && document.getElementById(portalRootID);
setShow(true); // const [show, setShow] = useState(false);
});
}, []);
const dialog = ( // useEffect(() => {
<Fragment> // window.requestAnimationFrame(() => {
<div // setShow(true);
data-testid="scrim" // });
key="scrim" // }, []);
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
>
<div
role="modal"
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
show ? 'scale-100 opacity-100' : ''
}`}
>
{children}
</div>
</div>
</Fragment>
);
return portalRoot ? createPortal(dialog, portalRoot) : dialog; // const dialog = (
} // <Fragment>
// <div
// data-testid="scrim"
// key="scrim"
// className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
// >
// <div
// role="modal"
// className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
// show ? 'scale-100 opacity-100' : ''
// }`}
// >
// {children}
// </div>
// </div>
// </Fragment>
// );
// return portalRoot ? createPortal(dialog, portalRoot) : dialog;
// }

View File

@ -1,16 +1,18 @@
import { h } from 'preact'; // import { h } from 'preact';
import { Link as RouterLink } from 'preact-router/match'; // import { Link as RouterLink } from 'preact-router/match';
export default function Link({ export {}
activeClassName = '',
className = 'text-blue-500 hover:underline', // export default function Link({
children, // activeClassName = '',
href, // className = 'text-blue-500 hover:underline',
...props // children,
}) { // href,
return ( // ...props
<RouterLink activeClassName={activeClassName} className={className} href={href} {...props}> // }) {
{children} // return (
</RouterLink> // <RouterLink activeClassName={activeClassName} className={className} href={href} {...props}>
); // {children}
} // </RouterLink>
// );
// }

View File

@ -1,48 +1,50 @@
import { h } from 'preact'; // import { h } from 'preact';
import RelativeModal from './RelativeModal'; // import RelativeModal from './RelativeModal';
import { useCallback } from 'preact/hooks'; // import { useCallback } from 'preact/hooks';
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) { export {}
return relativeTo ? (
<RelativeModal
children={children}
className={`${className || ''} py-2`}
role="listbox"
onDismiss={onDismiss}
portalRootID="menus"
relativeTo={relativeTo}
widthRelative={widthRelative}
/>
) : null;
}
export function MenuItem({ focus, icon: Icon, label, href, onSelect, value, ...attrs }) { // export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
const handleClick = useCallback(() => { // return relativeTo ? (
onSelect && onSelect(value, label); // <RelativeModal
}, [onSelect, value, label]); // children={children}
// className={`${className || ''} py-2`}
// role="listbox"
// onDismiss={onDismiss}
// portalRootID="menus"
// relativeTo={relativeTo}
// widthRelative={widthRelative}
// />
// ) : null;
// }
const Element = href ? 'a' : 'div'; // export function MenuItem({ focus, icon: Icon, label, href, onSelect, value, ...attrs }) {
// const handleClick = useCallback(() => {
// onSelect && onSelect(value, label);
// }, [onSelect, value, label]);
return ( // const Element = href ? 'a' : 'div';
<Element
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
}`}
href={href}
onClick={handleClick}
role="option"
{...attrs}
>
{Icon ? (
<div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
<Icon />
</div>
) : null}
<div className="whitespace-nowrap">{label}</div>
</Element>
);
}
export function MenuSeparator() { // return (
return <div className="border-b border-gray-200 dark:border-gray-800 my-2" />; // <Element
} // className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
// focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
// }`}
// href={href}
// onClick={handleClick}
// role="option"
// {...attrs}
// >
// {Icon ? (
// <div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
// <Icon />
// </div>
// ) : null}
// <div className="whitespace-nowrap">{label}</div>
// </Element>
// );
// }
// export function MenuSeparator() {
// return <div className="border-b border-gray-200 dark:border-gray-800 my-2" />;
// }

View File

@ -1,70 +1,72 @@
import { h } from 'preact'; // import { h } from 'preact';
import { useRef, useState } from 'preact/hooks'; // import { useRef, useState } from 'preact/hooks';
import Menu from './Menu'; // import Menu from './Menu';
import { ArrowDropdown } from '../icons/ArrowDropdown'; // import { ArrowDropdown } from '../icons/ArrowDropdown';
import Heading from './Heading'; // import Heading from './Heading';
import Button from './Button'; // import Button from './Button';
import SelectOnlyIcon from '../icons/SelectOnly'; // import SelectOnlyIcon from '../icons/SelectOnly';
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) { export {}
const popupRef = useRef(null);
const [state, setState] = useState({ // export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
showMenu: false, // const popupRef = useRef(null);
});
const isOptionSelected = (item) => { // const [state, setState] = useState({
return selection == 'all' || selection.split(',').indexOf(item) > -1; // showMenu: false,
}; // });
const menuHeight = Math.round(window.innerHeight * 0.55); // const isOptionSelected = (item) => {
return ( // return selection == 'all' || selection.split(',').indexOf(item) > -1;
<div className={`${className} p-2`} ref={popupRef}> // };
<div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
<label>{title}</label> // const menuHeight = Math.round(window.innerHeight * 0.55);
<ArrowDropdown className="w-6" /> // return (
</div> // <div className={`${className} p-2`} ref={popupRef}>
{state.showMenu ? ( // <div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
<Menu // <label>{title}</label>
className={`max-h-[${menuHeight}px] overflow-auto`} // <ArrowDropdown className="w-6" />
relativeTo={popupRef} // </div>
onDismiss={() => setState({ showMenu: false })} // {state.showMenu ? (
> // <Menu
<div className="flex flex-wrap justify-between items-center"> // className={`max-h-[${menuHeight}px] overflow-auto`}
<Heading className="p-4 justify-center" size="md"> // relativeTo={popupRef}
{title} // onDismiss={() => setState({ showMenu: false })}
</Heading> // >
<Button tabindex="false" className="mx-4" onClick={() => onShowAll()}> // <div className="flex flex-wrap justify-between items-center">
Show All // <Heading className="p-4 justify-center" size="md">
</Button> // {title}
</div> // </Heading>
{options.map((item) => ( // <Button tabindex="false" className="mx-4" onClick={() => onShowAll()}>
<div className="flex flex-grow" key={item}> // Show All
<label // </Button>
className={`flex flex-shrink space-x-2 p-1 my-1 min-w-[176px] hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer capitalize text-sm`} // </div>
> // {options.map((item) => (
<input // <div className="flex flex-grow" key={item}>
className="mx-4 m-0 align-middle" // <label
type="checkbox" // className={`flex flex-shrink space-x-2 p-1 my-1 min-w-[176px] hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer capitalize text-sm`}
checked={isOptionSelected(item)} // >
onChange={() => onToggle(item)} // <input
/> // className="mx-4 m-0 align-middle"
{item.replaceAll('_', ' ')} // type="checkbox"
</label> // checked={isOptionSelected(item)}
<div className="justify-right"> // onChange={() => onToggle(item)}
<Button // />
color={isOptionSelected(item) ? 'blue' : 'black'} // {item.replaceAll('_', ' ')}
type="text" // </label>
className="max-h-[35px] mx-2" // <div className="justify-right">
onClick={() => onSelectSingle(item)} // <Button
> // color={isOptionSelected(item) ? 'blue' : 'black'}
{ ( <SelectOnlyIcon /> ) } // type="text"
</Button> // className="max-h-[35px] mx-2"
</div> // onClick={() => onSelectSingle(item)}
</div> // >
))} // { ( <SelectOnlyIcon /> ) }
</Menu> // </Button>
) : null} // </div>
</div> // </div>
); // ))}
} // </Menu>
// ) : null}
// </div>
// );
// }

View File

@ -1,41 +1,43 @@
import { h } from 'preact'; // import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks'; // import { useCallback, useState } from 'preact/hooks';
export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) { export {}
const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp);
const handleSelected = useCallback( // export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) {
(index) => () => { // const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp);
setSelectedIndex(index);
onChange && onChange(index);
},
[onChange]
);
const RenderChildren = useCallback(() => { // const handleSelected = useCallback(
return children.map((child, i) => { // (index) => () => {
child.props.selected = i === selectedIndex; // setSelectedIndex(index);
child.props.onClick = handleSelected(i); // onChange && onChange(index);
return child; // },
}); // [onChange]
}, [selectedIndex, children, handleSelected]); // );
return ( // const RenderChildren = useCallback(() => {
<div className={`flex ${className}`}> // return children.map((child, i) => {
<RenderChildren /> // child.props.selected = i === selectedIndex;
</div> // child.props.onClick = handleSelected(i);
); // return child;
} // });
// }, [selectedIndex, children, handleSelected]);
export function TextTab({ selected, text, onClick, disabled }) { // return (
const selectedStyle = disabled // <div className={`flex ${className}`}>
? 'text-gray-400 dark:text-gray-600 bg-transparent' // <RenderChildren />
: selected // </div>
? 'text-white bg-blue-500 dark:text-black dark:bg-white' // );
: 'text-black dark:text-white bg-transparent'; // }
return (
<button onClick={onClick} disabled={disabled} className={`rounded-full px-4 py-2 ${selectedStyle}`}> // export function TextTab({ selected, text, onClick, disabled }) {
<span>{text}</span> // const selectedStyle = disabled
</button> // ? 'text-gray-400 dark:text-gray-600 bg-transparent'
); // : selected
} // ? 'text-white bg-blue-500 dark:text-black dark:bg-white'
// : 'text-black dark:text-white bg-transparent';
// return (
// <button onClick={onClick} disabled={disabled} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
// <span>{text}</span>
// </button>
// );
// }

View File

@ -1,83 +1,85 @@
import { FunctionComponent, useEffect, useMemo, useState } from 'react'; // import { FunctionComponent, useEffect, useMemo, useState } from 'react';
interface IProp { // interface IProp {
/** The time to calculate time-ago from */ // /** The time to calculate time-ago from */
time: Date; // time: Date;
/** OPTIONAL: overwrite current time */ // /** OPTIONAL: overwrite current time */
currentTime?: Date; // currentTime?: Date;
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */ // /** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
dense?: boolean; // dense?: boolean;
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */ // /** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
refreshInterval?: number; // refreshInterval?: number;
} // }
type TimeUnit = { export {}
unit: string;
full: string;
value: number;
};
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => { // type TimeUnit = {
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided'; // unit: string;
// full: string;
// value: number;
// };
const pastTime: Date = new Date(time); // const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
const elapsedTime: number = currentTime.getTime() - pastTime.getTime(); // if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
const timeUnits: TimeUnit[] = [ // const pastTime: Date = new Date(time);
{ unit: 'yr', full: 'year', value: 31536000 }, // const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
{ unit: 'mo', full: 'month', value: 0 },
{ unit: 'd', full: 'day', value: 86400 },
{ unit: 'h', full: 'hour', value: 3600 },
{ unit: 'm', full: 'minute', value: 60 },
{ unit: 's', full: 'second', value: 1 },
];
const elapsed: number = elapsedTime / 1000; // const timeUnits: TimeUnit[] = [
if (elapsed < 10) { // { unit: 'yr', full: 'year', value: 31536000 },
return 'just now'; // { unit: 'mo', full: 'month', value: 0 },
} // { unit: 'd', full: 'day', value: 86400 },
// { unit: 'h', full: 'hour', value: 3600 },
// { unit: 'm', full: 'minute', value: 60 },
// { unit: 's', full: 'second', value: 1 },
// ];
for (let i = 0; i < timeUnits.length; i++) { // const elapsed: number = elapsedTime / 1000;
// if months // if (elapsed < 10) {
if (i === 1) { // return 'just now';
// Get the month and year for the time provided // }
const pastMonth = pastTime.getUTCMonth();
const pastYear = pastTime.getUTCFullYear();
// get current month and year // for (let i = 0; i < timeUnits.length; i++) {
const currentMonth = currentTime.getUTCMonth(); // // if months
const currentYear = currentTime.getUTCFullYear(); // if (i === 1) {
// // Get the month and year for the time provided
// const pastMonth = pastTime.getUTCMonth();
// const pastYear = pastTime.getUTCFullYear();
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth); // // get current month and year
// const currentMonth = currentTime.getUTCMonth();
// const currentYear = currentTime.getUTCFullYear();
// check if the time provided is the previous month but not exceeded 1 month ago. // let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
monthDiff--;
}
if (monthDiff > 0) { // // check if the time provided is the previous month but not exceeded 1 month ago.
const unitAmount = monthDiff; // if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`; // monthDiff--;
} // }
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
}
}
return 'Invalid Time';
};
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => { // if (monthDiff > 0) {
const [currentTime, setCurrentTime] = useState<Date>(new Date()); // const unitAmount = monthDiff;
useEffect(() => { // return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
const intervalId: NodeJS.Timeout = setInterval(() => { // }
setCurrentTime(new Date()); // } else if (elapsed >= timeUnits[i].value) {
}, refreshInterval); // const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return () => clearInterval(intervalId); // return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
}, [refreshInterval]); // }
// }
// return 'Invalid Time';
// };
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]); // const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
// const [currentTime, setCurrentTime] = useState<Date>(new Date());
// useEffect(() => {
// const intervalId: NodeJS.Timeout = setInterval(() => {
// setCurrentTime(new Date());
// }, refreshInterval);
// return () => clearInterval(intervalId);
// }, [refreshInterval]);
return <span>{timeAgoValue}</span>; // const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
};
export default TimeAgo; // return <span>{timeAgoValue}</span>;
// };
// export default TimeAgo;

View File

@ -1,65 +1,67 @@
import { Fragment, h } from 'preact'; // import { Fragment, h } from 'preact';
import { useState } from 'preact/hooks'; // import { useState } from 'preact/hooks';
export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) { export {}
const boxLeftEdge = Math.round(eventOverlay.data.box[0] * 100);
const boxTopEdge = Math.round(eventOverlay.data.box[1] * 100);
const boxRightEdge = Math.round((1 - eventOverlay.data.box[2] - eventOverlay.data.box[0]) * 100);
const boxBottomEdge = Math.round((1 - eventOverlay.data.box[3] - eventOverlay.data.box[1]) * 100);
const [isHovering, setIsHovering] = useState(false); // export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) {
const getHoverStyle = () => { // const boxLeftEdge = Math.round(eventOverlay.data.box[0] * 100);
if (boxLeftEdge < 15) { // const boxTopEdge = Math.round(eventOverlay.data.box[1] * 100);
// show object stats on right side // const boxRightEdge = Math.round((1 - eventOverlay.data.box[2] - eventOverlay.data.box[0]) * 100);
return { // const boxBottomEdge = Math.round((1 - eventOverlay.data.box[3] - eventOverlay.data.box[1]) * 100);
left: `${boxLeftEdge + eventOverlay.data.box[2] * 100 + 1}%`,
top: `${boxTopEdge}%`,
};
}
return { // const [isHovering, setIsHovering] = useState(false);
right: `${boxRightEdge + eventOverlay.data.box[2] * 100 + 1}%`, // const getHoverStyle = () => {
top: `${boxTopEdge}%`, // if (boxLeftEdge < 15) {
}; // // show object stats on right side
}; // return {
// left: `${boxLeftEdge + eventOverlay.data.box[2] * 100 + 1}%`,
// top: `${boxTopEdge}%`,
// };
// }
const getObjectArea = () => { // return {
const width = eventOverlay.data.box[2] * cameraConfig.detect.width; // right: `${boxRightEdge + eventOverlay.data.box[2] * 100 + 1}%`,
const height = eventOverlay.data.box[3] * cameraConfig.detect.height; // top: `${boxTopEdge}%`,
return Math.round(width * height); // };
}; // };
const getObjectRatio = () => { // const getObjectArea = () => {
const width = eventOverlay.data.box[2] * cameraConfig.detect.width; // const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
const height = eventOverlay.data.box[3] * cameraConfig.detect.height; // const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
return Math.round(100 * (width / height)) / 100; // return Math.round(width * height);
}; // };
return ( // const getObjectRatio = () => {
<Fragment> // const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
<div // const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
className="absolute border-4 border-red-600" // return Math.round(100 * (width / height)) / 100;
onMouseEnter={() => setIsHovering(true)} // };
onMouseLeave={() => setIsHovering(false)}
onTouchStart={() => setIsHovering(true)} // return (
onTouchEnd={() => setIsHovering(false)} // <Fragment>
style={{ // <div
left: `${boxLeftEdge}%`, // className="absolute border-4 border-red-600"
top: `${boxTopEdge}%`, // onMouseEnter={() => setIsHovering(true)}
right: `${boxRightEdge}%`, // onMouseLeave={() => setIsHovering(false)}
bottom: `${boxBottomEdge}%`, // onTouchStart={() => setIsHovering(true)}
}} // onTouchEnd={() => setIsHovering(false)}
> // style={{
{eventOverlay.class_type == 'entered_zone' ? ( // left: `${boxLeftEdge}%`,
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] -translate-x-1/2 translate-y-3/4 bottom-0" /> // top: `${boxTopEdge}%`,
) : null} // right: `${boxRightEdge}%`,
</div> // bottom: `${boxBottomEdge}%`,
{isHovering && ( // }}
<div className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg" style={getHoverStyle()}> // >
<div>{`Area: ${getObjectArea()} px`}</div> // {eventOverlay.class_type == 'entered_zone' ? (
<div>{`Ratio: ${getObjectRatio()}`}</div> // <div className="absolute w-2 h-2 bg-yellow-500 left-[50%] -translate-x-1/2 translate-y-3/4 bottom-0" />
</div> // ) : null}
)} // </div>
</Fragment> // {isHovering && (
); // <div className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg" style={getHoverStyle()}>
} // <div>{`Area: ${getObjectArea()} px`}</div>
// <div>{`Ratio: ${getObjectRatio()}`}</div>
// </div>
// )}
// </Fragment>
// );
// }

View File

@ -1,218 +1,220 @@
import { h } from 'preact'; // import { h } from 'preact';
import useSWR from 'swr'; // import useSWR from 'swr';
import ActivityIndicator from './ActivityIndicator'; // import ActivityIndicator from './ActivityIndicator';
import { formatUnixTimestampToDateTime } from '../utils/dateUtil'; // import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
import About from '../icons/About'; // import About from '../icons/About';
import ActiveObjectIcon from '../icons/ActiveObject'; // import ActiveObjectIcon from '../icons/ActiveObject';
import PlayIcon from '../icons/Play'; // import PlayIcon from '../icons/Play';
import ExitIcon from '../icons/Exit'; // import ExitIcon from '../icons/Exit';
import StationaryObjectIcon from '../icons/StationaryObject'; // import StationaryObjectIcon from '../icons/StationaryObject';
import FaceIcon from '../icons/Face'; // import FaceIcon from '../icons/Face';
import LicensePlateIcon from '../icons/LicensePlate'; // import LicensePlateIcon from '../icons/LicensePlate';
import DeliveryTruckIcon from '../icons/DeliveryTruck'; // import DeliveryTruckIcon from '../icons/DeliveryTruck';
import ZoneIcon from '../icons/Zone'; // import ZoneIcon from '../icons/Zone';
import { useMemo, useState } from 'preact/hooks'; // import { useMemo, useState } from 'preact/hooks';
import Button from './Button'; // import Button from './Button';
export default function TimelineSummary({ event, onFrameSelected }) { export {}
const { data: eventTimeline } = useSWR([
'timeline',
{
source_id: event.id,
},
]);
const { data: config } = useSWR('config'); // export default function TimelineSummary({ event, onFrameSelected }) {
// const { data: eventTimeline } = useSWR([
// 'timeline',
// {
// source_id: event.id,
// },
// ]);
const annotationOffset = useMemo(() => { // const { data: config } = useSWR('config');
if (!config) {
return 0;
}
return (config.cameras[event.camera]?.detect?.annotation_offset || 0) / 1000; // const annotationOffset = useMemo(() => {
}, [config, event]); // if (!config) {
// return 0;
// }
const [timeIndex, setTimeIndex] = useState(-1); // return (config.cameras[event.camera]?.detect?.annotation_offset || 0) / 1000;
// }, [config, event]);
const recordingParams = useMemo(() => { // const [timeIndex, setTimeIndex] = useState(-1);
if (!event.end_time) {
return {
after: event.start_time,
};
}
return { // const recordingParams = useMemo(() => {
before: event.end_time, // if (!event.end_time) {
after: event.start_time, // return {
}; // after: event.start_time,
}, [event]); // };
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false }); // }
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time // return {
const getSeekSeconds = (seekUnix) => { // before: event.end_time,
if (!recordings) { // after: event.start_time,
return 0; // };
} // }, [event]);
// const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
let seekSeconds = 0; // // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
recordings.every((segment) => { // const getSeekSeconds = (seekUnix) => {
// if the next segment is past the desired time, stop calculating // if (!recordings) {
if (segment.start_time > seekUnix) { // return 0;
return false; // }
}
if (segment.end_time < seekUnix) { // let seekSeconds = 0;
seekSeconds += segment.end_time - segment.start_time; // recordings.every((segment) => {
return true; // // if the next segment is past the desired time, stop calculating
} // if (segment.start_time > seekUnix) {
// return false;
// }
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix); // if (segment.end_time < seekUnix) {
return true; // seekSeconds += segment.end_time - segment.start_time;
}); // return true;
// }
return seekSeconds; // seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
}; // return true;
// });
const onSelectMoment = async (index) => { // return seekSeconds;
setTimeIndex(index); // };
onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp + annotationOffset));
};
if (!eventTimeline || !config) { // const onSelectMoment = async (index) => {
return <ActivityIndicator />; // setTimeIndex(index);
} // onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp + annotationOffset));
// };
if (eventTimeline.length == 0) { // if (!eventTimeline || !config) {
return <div />; // return <ActivityIndicator />;
} // }
return ( // if (eventTimeline.length == 0) {
<div className="flex flex-col"> // return <div />;
<div className="h-14 flex justify-center"> // }
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
{eventTimeline.map((item, index) => (
<Button
key={index}
className="rounded-full"
type="iconOnly"
color={index == timeIndex ? 'blue' : 'gray'}
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
onClick={() => onSelectMoment(index)}
>
{getTimelineIcon(item)}
</Button>
))}
</div>
</div>
{timeIndex >= 0 ? (
<div className="m-2 max-w-md self-center">
<div className="flex justify-start">
<div className="text-lg flex justify-between py-4">Bounding boxes may not align</div>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label=" Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
streams are perfectly in sync so the bounding box and the footage will not line up perfectly. The annotation_offset field can be used to adjust this."
>
<About className="w-4" />
</Button>
</div>
</div>
) : null}
</div>
);
}
function getTimelineIcon(timelineItem) { // return (
switch (timelineItem.class_type) { // <div className="flex flex-col">
case 'visible': // <div className="h-14 flex justify-center">
return <PlayIcon className="w-8" />; // <div className="flex flex-row flex-nowrap justify-between overflow-auto">
case 'gone': // {eventTimeline.map((item, index) => (
return <ExitIcon className="w-8" />; // <Button
case 'active': // key={index}
return <ActiveObjectIcon className="w-8" />; // className="rounded-full"
case 'stationary': // type="iconOnly"
return <StationaryObjectIcon className="w-8" />; // color={index == timeIndex ? 'blue' : 'gray'}
case 'entered_zone': // aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
return <ZoneIcon className="w-8" />; // onClick={() => onSelectMoment(index)}
case 'attribute': // >
switch (timelineItem.data.attribute) { // {getTimelineIcon(item)}
case 'face': // </Button>
return <FaceIcon className="w-8" />; // ))}
case 'license_plate': // </div>
return <LicensePlateIcon className="w-8" />; // </div>
default: // {timeIndex >= 0 ? (
return <DeliveryTruckIcon className="w-8" />; // <div className="m-2 max-w-md self-center">
} // <div className="flex justify-start">
case 'sub_label': // <div className="text-lg flex justify-between py-4">Bounding boxes may not align</div>
switch (timelineItem.data.label) { // <Button
case 'person': // className="rounded-full"
return <FaceIcon className="w-8" />; // type="text"
case 'car': // color="gray"
return <LicensePlateIcon className="w-8" />; // aria-label=" Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
} // streams are perfectly in sync so the bounding box and the footage will not line up perfectly. The annotation_offset field can be used to adjust this."
} // >
} // <About className="w-4" />
// </Button>
// </div>
// </div>
// ) : null}
// </div>
// );
// }
function getTimelineItemDescription(config, timelineItem, event) { // function getTimelineIcon(timelineItem) {
switch (timelineItem.class_type) { // switch (timelineItem.class_type) {
case 'visible': // case 'visible':
return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, { // return <PlayIcon className="w-8" />;
date_style: 'short', // case 'gone':
time_style: 'medium', // return <ExitIcon className="w-8" />;
time_format: config.ui.time_format, // case 'active':
})}`; // return <ActiveObjectIcon className="w-8" />;
case 'entered_zone': // case 'stationary':
return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones // return <StationaryObjectIcon className="w-8" />;
.join(' and ') // case 'entered_zone':
.replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, { // return <ZoneIcon className="w-8" />;
date_style: 'short', // case 'attribute':
time_style: 'medium', // switch (timelineItem.data.attribute) {
time_format: config.ui.time_format, // case 'face':
})}`; // return <FaceIcon className="w-8" />;
case 'active': // case 'license_plate':
return `${event.label} became active at ${formatUnixTimestampToDateTime(timelineItem.timestamp, { // return <LicensePlateIcon className="w-8" />;
date_style: 'short', // default:
time_style: 'medium', // return <DeliveryTruckIcon className="w-8" />;
time_format: config.ui.time_format, // }
})}`; // case 'sub_label':
case 'stationary': // switch (timelineItem.data.label) {
return `${event.label} became stationary at ${formatUnixTimestampToDateTime(timelineItem.timestamp, { // case 'person':
date_style: 'short', // return <FaceIcon className="w-8" />;
time_style: 'medium', // case 'car':
time_format: config.ui.time_format, // return <LicensePlateIcon className="w-8" />;
})}`; // }
case 'attribute': { // }
let title = ""; // }
if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
title = `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label}`; // function getTimelineItemDescription(config, timelineItem, event) {
} else { // switch (timelineItem.class_type) {
title = `${event.label} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}` // case 'visible':
} // return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
return `${title} at ${formatUnixTimestampToDateTime( // date_style: 'short',
timelineItem.timestamp, // time_style: 'medium',
{ // time_format: config.ui.time_format,
date_style: 'short', // })}`;
time_style: 'medium', // case 'entered_zone':
time_format: config.ui.time_format, // return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones
} // .join(' and ')
)}`; // .replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
} // date_style: 'short',
case 'sub_label': // time_style: 'medium',
return `${event.label} recognized as ${timelineItem.data.sub_label} at ${formatUnixTimestampToDateTime( // time_format: config.ui.time_format,
timelineItem.timestamp, // })}`;
{ // case 'active':
date_style: 'short', // return `${event.label} became active at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
time_style: 'medium', // date_style: 'short',
time_format: config.ui.time_format, // time_style: 'medium',
} // time_format: config.ui.time_format,
)}`; // })}`;
case 'gone': // case 'stationary':
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, { // return `${event.label} became stationary at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
date_style: 'short', // date_style: 'short',
time_style: 'medium', // time_style: 'medium',
time_format: config.ui.time_format, // time_format: config.ui.time_format,
})}`; // })}`;
} // case 'attribute': {
} // let title = "";
// if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
// title = `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label}`;
// } else {
// title = `${event.label} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`
// }
// return `${title} at ${formatUnixTimestampToDateTime(
// timelineItem.timestamp,
// {
// date_style: 'short',
// time_style: 'medium',
// time_format: config.ui.time_format,
// }
// )}`;
// }
// case 'sub_label':
// return `${event.label} recognized as ${timelineItem.data.sub_label} at ${formatUnixTimestampToDateTime(
// timelineItem.timestamp,
// {
// date_style: 'short',
// time_style: 'medium',
// time_format: config.ui.time_format,
// }
// )}`;
// case 'gone':
// return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
// date_style: 'short',
// time_style: 'medium',
// time_format: config.ui.time_format,
// })}`;
// }
// }

View File

@ -1,62 +1,64 @@
import { createPortal } from 'react'; // TODO implement // import { createPortal } from 'react'; // TODO implement
import { useLayoutEffect, useRef, useState } from 'react'; // import { useLayoutEffect, useRef, useState } from 'react';
const TIP_SPACE = 20; // const TIP_SPACE = 20;
export default function Tooltip({ relativeTo, text, capitalize }) { export {}
const [position, setPosition] = useState({ top: -9999, left: -9999 });
const portalRoot = document.getElementById('tooltips');
const ref = useRef();
useLayoutEffect(() => { // export default function Tooltip({ relativeTo, text, capitalize }) {
if (ref && ref.current && relativeTo && relativeTo.current) { // const [position, setPosition] = useState({ top: -9999, left: -9999 });
const windowWidth = window.innerWidth; // const portalRoot = document.getElementById('tooltips');
const { // const ref = useRef();
x: relativeToX,
y: relativeToY,
width: relativeToWidth,
height: relativeToHeight,
} = relativeTo.current.getBoundingClientRect();
const { width: _tipWidth, height: _tipHeight } = ref.current.getBoundingClientRect();
const tipWidth = _tipWidth * 1.1;
const tipHeight = _tipHeight * 1.1;
const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX; // useLayoutEffect(() => {
const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY; // if (ref && ref.current && relativeTo && relativeTo.current) {
// const windowWidth = window.innerWidth;
// const {
// x: relativeToX,
// y: relativeToY,
// width: relativeToWidth,
// height: relativeToHeight,
// } = relativeTo.current.getBoundingClientRect();
// const { width: _tipWidth, height: _tipHeight } = ref.current.getBoundingClientRect();
// const tipWidth = _tipWidth * 1.1;
// const tipHeight = _tipHeight * 1.1;
let newTop = top - TIP_SPACE - tipHeight; // const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
let newLeft = left - Math.round(tipWidth / 2); // const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
// too far right
if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
newLeft = Math.max(0, left - tipWidth - TIP_SPACE);
newTop = top - Math.round(tipHeight / 2);
}
// too far left
else if (newLeft < TIP_SPACE + window.scrollX) {
newLeft = left + TIP_SPACE;
newTop = top - Math.round(tipHeight / 2);
}
// too close to top
else if (newTop <= TIP_SPACE + window.scrollY) {
newTop = top + tipHeight + TIP_SPACE;
}
setPosition({ left: newLeft, top: newTop }); // let newTop = top - TIP_SPACE - tipHeight;
} // let newLeft = left - Math.round(tipWidth / 2);
}, [relativeTo, ref]); // // too far right
// if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
// newLeft = Math.max(0, left - tipWidth - TIP_SPACE);
// newTop = top - Math.round(tipHeight / 2);
// }
// // too far left
// else if (newLeft < TIP_SPACE + window.scrollX) {
// newLeft = left + TIP_SPACE;
// newTop = top - Math.round(tipHeight / 2);
// }
// // too close to top
// else if (newTop <= TIP_SPACE + window.scrollY) {
// newTop = top + tipHeight + TIP_SPACE;
// }
const tooltip = ( // setPosition({ left: newLeft, top: newTop });
<div // }
role="tooltip" // }, [relativeTo, ref]);
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
capitalize ? 'capitalize' : ''
} ${position.top >= 0 ? 'opacity-100 scale-100' : ''}`}
ref={ref}
style={position}
>
{text}
</div>
);
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip; // const tooltip = (
} // <div
// role="tooltip"
// className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
// capitalize ? 'capitalize' : ''
// } ${position.top >= 0 ? 'opacity-100 scale-100' : ''}`}
// ref={ref}
// style={position}
// >
// {text}
// </div>
// );
// return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
// }

View File

@ -1,80 +1,81 @@
import * as React from "react" // import * as React from "react"
export {}
const Card = React.forwardRef< // const Card = React.forwardRef<
HTMLDivElement, // HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> // React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( // >(({ className, ...props }, ref) => (
<div // <div
ref={ref} // ref={ref}
// className={cn( // // className={cn(
// "rounded-lg border bg-card text-card-foreground shadow-sm", // // "rounded-lg border bg-card text-card-foreground shadow-sm",
// className // // className
// )} // // )}
{...props} // {...props}
/> // />
)) // ))
Card.displayName = "Card" // Card.displayName = "Card"
const CardHeader = React.forwardRef< // const CardHeader = React.forwardRef<
HTMLDivElement, // HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> // React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( // >(({ className, ...props }, ref) => (
<div // <div
ref={ref} // ref={ref}
// className={cn("flex flex-col space-y-1.5 p-6", className)} // // className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} // {...props}
/> // />
)) // ))
CardHeader.displayName = "CardHeader" // CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< // const CardTitle = React.forwardRef<
HTMLParagraphElement, // HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement> // React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => ( // >(({ className, ...props }, ref) => (
<h3 // <h3
ref={ref} // ref={ref}
// className={cn( // // className={cn(
// "text-2xl font-semibold leading-none tracking-tight", // // "text-2xl font-semibold leading-none tracking-tight",
// className // // className
// )} // // )}
{...props} // {...props}
/> // />
)) // ))
CardTitle.displayName = "CardTitle" // CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef< // const CardDescription = React.forwardRef<
HTMLParagraphElement, // HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> // React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( // >(({ className, ...props }, ref) => (
<p // <p
ref={ref} // ref={ref}
// className={cn("text-sm text-muted-foreground", className)} // // className={cn("text-sm text-muted-foreground", className)}
{...props} // {...props}
/> // />
)) // ))
CardDescription.displayName = "CardDescription" // CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef< // const CardContent = React.forwardRef<
HTMLDivElement, // HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> // React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( // >(({ className, ...props }, ref) => (
<div ref={ref} // <div ref={ref}
// className= {cn("p-6 pt-0", className)} // // className= {cn("p-6 pt-0", className)}
{...props} /> // {...props} />
)) // ))
CardContent.displayName = "CardContent" // CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef< // const CardFooter = React.forwardRef<
HTMLDivElement, // HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> // React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( // >(({ className, ...props }, ref) => (
<div // <div
ref={ref} // ref={ref}
// className={cn("flex items-center p-6 pt-0", className)} // // className={cn("flex items-center p-6 pt-0", className)}
{...props} // {...props}
/> // />
)) // ))
CardFooter.displayName = "CardFooter" // CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } // export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -1,19 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function About({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`${className}`}
>
<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
</svg>
);
}
export default memo(About);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function CalendarIcon({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
);
}
export default memo(CalendarIcon);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Camera({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
}
export default memo(Camera);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Clip({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"
/>
</svg>
);
}
export default memo(Clip);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Clock({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export default memo(Clock);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Delete({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
);
}
export default memo(Delete);

View File

@ -1,26 +0,0 @@
import { memo } from 'react';
export function Download({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => void }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
);
}
export default memo(Download);

View File

@ -1,13 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Menu({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
</svg>
);
}
export default memo(Menu);

View File

@ -1,13 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function MenuOpen({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 18h13v-2H3v2zm0-5h10v-2H3v2zm0-7v2h13V6H3zm18 9.59L17.42 12 21 8.41 19.59 7l-5 5 5 5L21 15.59z" />
</svg>
);
}
export default memo(MenuOpen);

View File

@ -1,20 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Score({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<title>percent</title>
<path d="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M19,19H15V21H19A2,2 0 0,0 21,19V15H19M19,3H15V5H19V9H21V5A2,2 0 0,0 19,3M5,5H9V3H5A2,2 0 0,0 3,5V9H5M5,15H3V19A2,2 0 0,0 5,21H9V19H5V15Z" />
</svg>
);
}
export default memo(Score);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Snapshot({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
);
}
export default memo(Snapshot);

View File

@ -1,24 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function StarRecording({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
);
}
export default memo(StarRecording);

View File

@ -1,19 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Submitted({ className = 'h-6 w-6', inner_fill = 'none', outer_stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 32 32"
onClick={onClick}
>
<rect x="10" y="15" fill={inner_fill} width="12" height="2"/>
<rect x="15" y="10" fill={inner_fill} width="2" height="12"/>
<circle fill="none" stroke={outer_stroke} stroke-width="2" stroke-miterlimit="10" cx="16" cy="16" r="12"/>
</svg>
);
}
export default memo(Submitted);

View File

@ -1,23 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function UploadPlus({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
);
}
export default memo(UploadPlus);

View File

@ -1,25 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Zone({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill={fill}
viewBox="0 0 24 24"
stroke={stroke}
onClick={onClick}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
export default memo(Zone);

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Center, DEFAULT_THEME } from '@mantine/core'; import { Center, DEFAULT_THEME } from '@mantine/core';
import CogwheelSVG from './svg/CogwheelSVG'; import CogwheelSVG from '../svg/CogwheelSVG';
const CogwheelLoader = () => { const CogwheelLoader = () => {

View File

@ -15,7 +15,7 @@ export class RecordingsStore {
makeAutoObservable(this) makeAutoObservable(this)
} }
recordingSchema = z.object({ private _recordingSchema = z.object({
hostName: z.string(), hostName: z.string(),
cameraName: z.string(), cameraName: z.string(),
hour: z.string(), hour: z.string(),
@ -31,7 +31,7 @@ export class RecordingsStore {
this._recordToPlay = value this._recordToPlay = value
} }
getFullRecordForPlay(value: RecordForPlay) { getFullRecordForPlay(value: RecordForPlay) {
return this.recordingSchema.safeParse(value) return this._recordingSchema.safeParse(value)
} }
private _hostIdParam: string | undefined private _hostIdParam: string | undefined
@ -63,6 +63,11 @@ export class RecordingsStore {
public set selectedCamera(value: GetCameraWHostWConfig | undefined) { public set selectedCamera(value: GetCameraWHostWConfig | undefined) {
this._selectedCamera = value this._selectedCamera = value
} }
selectedStartDay: string = '' private _selectedRange: [Date | null, Date | null] = [null, null]
selectedEndDay: string = '' public get selectedRange(): [Date | null, Date | null] {
return this._selectedRange
}
public set selectedRange(value: [Date | null, Date | null]) {
this._selectedRange = value
}
} }

View File

@ -1,10 +1,31 @@
export const strings = { export const strings = {
host: { host: 'Хост',
hostArr: {
name: 'Имя хоста', name: 'Имя хоста',
url: 'Адрес', url: 'Адрес',
enabled: 'Включен', enabled: 'Включен',
}, },
// user section player: {
startVideo: 'Вкл Видео',
stopVideo: 'Выкл Видео',
object: 'Объект',
duration: 'Длительность',
startTime: 'Начало',
endTime: 'Конец'
},
camera: 'Камера',
hour: 'Час',
minute: 'Минута',
second: 'Секунда',
events: 'События',
event: 'Событие',
notHaveEvents: 'Нет событий',
date: 'Дата',
day: 'День',
selectHost:'Выбери хост',
selectCamera: 'Выбери камеру',
selectDate: 'Выбери дату',
selectRange: 'Выбери период',
aboutMe: "Обо мне", aboutMe: "Обо мне",
settings: "Настройки", settings: "Настройки",
changeTheme: "Изменить тему", changeTheme: "Изменить тему",

View File

@ -30,6 +30,33 @@ export const unixTimeToDate = (unixTime: number) => {
return formattedDate; return formattedDate;
} }
/**
* @param date
* @returns string '2024-02-25'
*/
export const dateToQueryString = (date: Date): string => {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const formattedMonth = month < 10 ? `0${month}` : month;
const day = date.getDate();
const formattedDay = day < 10 ? `0${day}` : day;
return `${year}-${formattedMonth}-${formattedDay}`;
}
export const parseQueryDateToDate = (dateQuery: string): Date | null => {
const match = dateQuery.match(/(\d{4})-(\d{2})-(\d{2})/);
if (match) {
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10) - 1;
const day = parseInt(match[3], 10);
return new Date(year, month, day);
}
return null;
}
/** /**
* @param day frigate format, e.g day: 2024-02-23 * @param day frigate format, e.g day: 2024-02-23
* @param hour frigate format, e.g hour: 22 * @param hour frigate format, e.g hour: 22
@ -210,14 +237,14 @@ interface DurationToken {
* @param end_time: number|null - Unix timestamp for end time * @param end_time: number|null - Unix timestamp for end time
* @returns string - duration or 'In Progress' if end time is not provided * @returns string - duration or 'In Progress' if end time is not provided
*/ */
export const getDurationFromTimestamps = (start_time: number, end_time: number | undefined): string => { export const getDurationFromTimestamps = (start_time: number, end_time: number | undefined): string | undefined => {
if (isNaN(start_time)) { if (isNaN(start_time)) {
return 'Invalid start time'; return
} }
let duration = 'In Progress'; let duration = 'In Progress';
if (end_time) { if (end_time) {
if (isNaN(end_time)) { if (isNaN(end_time)) {
return 'Invalid end time'; return
} }
const start = fromUnixTime(start_time); const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time); const end = fromUnixTime(end_time);

View File

@ -61,9 +61,9 @@ const FrigateHostsTable = ({ data, showAddButton = false, saveCallback, changedC
} }
const headTitle = [ const headTitle = [
{ propertyName: 'name', title: strings.host.name }, { propertyName: 'name', title: strings.hostArr.name },
{ propertyName: 'host', title: strings.host.url }, { propertyName: 'host', title: strings.hostArr.url },
{ propertyName: 'enabled', title: strings.host.enabled }, { propertyName: 'enabled', title: strings.hostArr.enabled },
{ title: '', sorting: false }, { title: '', sorting: false },
] ]

View File

@ -1,14 +1,14 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import JSMpegPlayer from './JSMpegPlayer'; import JSMpegPlayer from '../shared/components/players/JSMpegPlayer';
import MSEPlayer from './MsePlayer'; import MSEPlayer from '../shared/components/players/MsePlayer';
import { CameraConfig } from '../../../types/frigateConfig'; import { CameraConfig } from '../types/frigateConfig';
import { LivePlayerMode } from '../../../types/live'; import { LivePlayerMode } from '../types/live';
import useCameraActivity from '../../../hooks/use-camera-activity'; import useCameraActivity from '../hooks/use-camera-activity';
import useCameraLiveMode from '../../../hooks/use-camera-live-mode'; import useCameraLiveMode from '../hooks/use-camera-live-mode';
import WebRtcPlayer from './WebRTCPlayer'; import WebRtcPlayer from '../shared/components/players/WebRTCPlayer';
import { Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
import { frigateApi, proxyApi } from '../../../services/frigate.proxy/frigate.api'; import { frigateApi, proxyApi } from '../services/frigate.proxy/frigate.api';
import { GetCameraWHostWConfig } from '../../../services/frigate.proxy/frigate.schema'; import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema';
type LivePlayerProps = { type LivePlayerProps = {
camera: GetCameraWHostWConfig; camera: GetCameraWHostWConfig;
@ -70,7 +70,7 @@ const Player = ({
player = ( player = (
<MSEPlayer <MSEPlayer
className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`} className={`rounded-2xl h-full ${liveReady ? "" : "hidden"}`}
camera='Not yet implemented' camera='Not yet implemented' // TODO implement player
playbackEnabled={cameraActive} playbackEnabled={cameraActive}
onPlaying={() => setLiveReady(true)} onPlaying={() => setLiveReady(true)}
wsUrl={wsUrl} wsUrl={wsUrl}

View File

@ -1,12 +1,9 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext } from 'react';
import OneSelectFilter, { OneSelectItem } from '../shared/components/filters.aps/OneSelectFilter';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Context } from '..'; import { Context } from '..';
import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import { Center, Text } from '@mantine/core';
import CogwheelLoader from '../shared/components/CogwheelLoader';
import CameraSelectFilter from '../shared/components/filters.aps/CameraSelectFilter'; import CameraSelectFilter from '../shared/components/filters.aps/CameraSelectFilter';
import DateRangeSelectFilter from '../shared/components/filters.aps/DateRangeSelectFilter';
import HostSelectFilter from '../shared/components/filters.aps/HostSelectFilter';
interface RecordingsFiltersRightSideProps { interface RecordingsFiltersRightSideProps {
} }
@ -15,58 +12,18 @@ const RecordingsFiltersRightSide = ({
}: RecordingsFiltersRightSideProps) => { }: RecordingsFiltersRightSideProps) => {
const { recordingsStore: recStore } = useContext(Context) const { recordingsStore: recStore } = useContext(Context)
const { data: hosts, isError, isPending, isSuccess } = useQuery({
queryKey: [frigateQueryKeys.getFrigateHosts],
queryFn: frigateApi.getHosts
})
useEffect(() => {
if (!hosts) return
if (recStore.hostIdParam) {
recStore.selectedHost = hosts.find(host => host.id === recStore.hostIdParam)
recStore.hostIdParam = undefined
}
}, [isSuccess])
if (isPending) return <CogwheelLoader />
if (isError) return <Center><Text>Loading error!</Text></Center>
if (!hosts || hosts.length < 1) return null
const hostItems: OneSelectItem[] = hosts
.filter(host => host.enabled)
.map(host => ({ value: host.id, label: host.name }))
const handleSelect = (id: string, value: string) => {
const host = hosts?.find(host => host.id === value)
if (!host) {
recStore.selectedHost = undefined
recStore.selectedCamera = undefined
return
}
if (recStore.selectedHost?.id !== host.id) {
recStore.selectedCamera = undefined
}
recStore.selectedHost = host
}
console.log('RecordingsFiltersRightSide rendered') console.log('RecordingsFiltersRightSide rendered')
return ( return (
<> <>
<OneSelectFilter <HostSelectFilter />
id='frigate-hosts'
label='Select host:'
spaceBetween='1rem'
value={recStore.selectedHost?.id || ''}
defaultValue={recStore.selectedHost?.id || ''}
data={hostItems}
onChange={handleSelect}
/>
{recStore.selectedHost ? {recStore.selectedHost ?
<CameraSelectFilter <CameraSelectFilter
selectedHostId={recStore.selectedHost.id} /> selectedHostId={recStore.selectedHost.id} />
: : <></>
<></> }
{recStore.selectedCamera ?
<DateRangeSelectFilter />
: <></>
} }
</> </>

View File

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import React, { useContext } from 'react';
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api';
import { dateToQueryString, getResolvedTimeZone } from '../shared/utils/dateUtil';
import { Context } from '..';
import { Flex, Text } from '@mantine/core';
import RetryErrorPage from '../pages/RetryErrorPage';
import CenterLoader from '../shared/components/CenterLoader';
import { observer } from 'mobx-react-lite';
import DayAccordion from '../shared/components/accordion/DayAccordion';
interface SelecteDayListProps {
day: Date
}
const SelecteDayList = ({
day
}: SelecteDayListProps) => {
const { recordingsStore: recStore } = useContext(Context)
const camera = recStore.selectedCamera
const host = recStore.selectedHost
const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getRecordingsSummary, recStore.selectedCamera?.id, day],
queryFn: async () => {
if (camera && host) {
const stringDay = dateToQueryString(day)
const hostName = mapHostToHostname(host)
const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
return res.find(record => record.day === stringDay)
}
return null
}
})
const handleRetry = () => {
if (recStore.selectedHost) refetch()
}
if (isPending) return <CenterLoader />
if (isError) return <RetryErrorPage onRetry={handleRetry} />
if (!camera || !host || !data) return <CenterLoader />
return (
<Flex w='100%' h='100%' direction='column' align='center'>
<Text>{host.name} / {camera.name} / {data.day}</Text>
<DayAccordion recordSummary={data} />
</Flex>
);
};
export default observer(SelecteDayList);

View File

@ -6,24 +6,25 @@ import { useQuery } from '@tanstack/react-query';
import { Context } from '..'; import { Context } from '..';
import { frigateQueryKeys, frigateApi } from '../services/frigate.proxy/frigate.api'; import { frigateQueryKeys, frigateApi } from '../services/frigate.proxy/frigate.api';
import { host } from '../shared/env.const'; import { host } from '../shared/env.const';
import CogwheelLoader from '../shared/components/CogwheelLoader'; import CogwheelLoader from '../shared/components/loaders/CogwheelLoader';
import RetryError from '../pages/RetryError'; import RetryErrorPage from '../pages/RetryErrorPage';
import CenterLoader from '../shared/components/CenterLoader';
interface SelectedCameraListProps { interface SelectedCameraListProps {
cameraId: string, // cameraId: string,
} }
const SelectedCameraList = ({ const SelectedCameraList = ({
cameraId, // cameraId,
}: SelectedCameraListProps) => { }: SelectedCameraListProps) => {
const { recordingsStore: recStore } = useContext(Context) const { recordingsStore: recStore } = useContext(Context)
const { data: camera, isPending: cameraPending, isError: cameraError, refetch: cameraRefetch } = useQuery({ const { data: camera, isPending: cameraPending, isError: cameraError, refetch: cameraRefetch } = useQuery({
queryKey: [frigateQueryKeys.getCameraWHost, cameraId], queryKey: [frigateQueryKeys.getCameraWHost, recStore.selectedCamera?.id],
queryFn: async () => { queryFn: async () => {
if (cameraId) { if (recStore.selectedCamera) {
return frigateApi.getCameraWHost(cameraId) return frigateApi.getCameraWHost(recStore.selectedCamera.id)
} }
return null return null
} }
@ -33,14 +34,18 @@ const SelectedCameraList = ({
cameraRefetch() cameraRefetch()
} }
if (cameraPending) return <CogwheelLoader /> if (cameraPending) return <CenterLoader />
if (cameraError) return <RetryError onRetry={handleRetry} /> if (cameraError) return <RetryErrorPage onRetry={handleRetry} />
if (!camera?.frigateHost) return null if (!camera?.frigateHost) return null
return ( return (
<Flex w='100%' h='100%' direction='column' align='center'> <Flex w='100%' h='100%' direction='column' align='center'>
<Text>{camera.frigateHost.name} / {camera.name}</Text> <Text>{camera.frigateHost.name} / {camera.name}</Text>
{
}
<Suspense> <Suspense>
<CameraAccordion camera={camera} host={camera.frigateHost} /> <CameraAccordion camera={camera} host={camera.frigateHost} />
</Suspense> </Suspense>

View File

@ -5,7 +5,8 @@ import { useQuery } from '@tanstack/react-query';
import { frigateQueryKeys, frigateApi } from '../services/frigate.proxy/frigate.api'; import { frigateQueryKeys, frigateApi } from '../services/frigate.proxy/frigate.api';
import { Context } from '..'; import { Context } from '..';
import CenterLoader from '../shared/components/CenterLoader'; import CenterLoader from '../shared/components/CenterLoader';
import RetryError from '../pages/RetryError'; import RetryErrorPage from '../pages/RetryErrorPage';
import { strings } from '../shared/strings/strings';
const CameraAccordion = lazy(() => import('../shared/components/accordion/CameraAccordion')); const CameraAccordion = lazy(() => import('../shared/components/accordion/CameraAccordion'));
@ -39,14 +40,14 @@ const SelectedHostList = ({
} }
if (hostPending) return <CenterLoader /> if (hostPending) return <CenterLoader />
if (hostError) return <RetryError onRetry={handleRetry} /> if (hostError) return <RetryErrorPage onRetry={handleRetry} />
if (!host || host.cameras.length < 1) return null if (!host || host.cameras.length < 1) return null
const cameras = host.cameras.slice(0, 2).map(camera => { const cameras = host.cameras.slice(0, 2).map(camera => {
return ( return (
<Accordion.Item key={camera.id + 'Item'} value={camera.id}> <Accordion.Item key={camera.id + 'Item'} value={camera.id}>
<Accordion.Control key={camera.id + 'Control'}>{camera.name}</Accordion.Control> <Accordion.Control key={camera.id + 'Control'}>{strings.camera}: {camera.name}</Accordion.Control>
<Accordion.Panel key={camera.id + 'Panel'}> <Accordion.Panel key={camera.id + 'Panel'}>
{openCameraId === camera.id && ( {openCameraId === camera.id && (
<Suspense> <Suspense>
@ -60,7 +61,7 @@ const SelectedHostList = ({
return ( return (
<Flex w='100%' h='100%' direction='column' align='center'> <Flex w='100%' h='100%' direction='column' align='center'>
<Text>{host.name}</Text> <Text>{strings.host}: {host.name}</Text>
<Accordion <Accordion
mt='1rem' mt='1rem'
variant='separated' variant='separated'