add downloading record & event
add search add events props decompose events accordion
This commit is contained in:
parent
26c5c6504a
commit
8cdcb6620f
@ -9,6 +9,7 @@
|
||||
"@mantine/core": "^6.0.16",
|
||||
"@mantine/dates": "^6.0.16",
|
||||
"@mantine/hooks": "^6.0.16",
|
||||
"@mantine/notifications": "6.0.16",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@tabler/icons-react": "^2.24.0",
|
||||
"@tanstack/react-query": "^5.21.2",
|
||||
|
||||
@ -8,6 +8,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import AppBody from './AppBody';
|
||||
import Forbidden from './pages/403';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
|
||||
|
||||
function App() {
|
||||
@ -62,6 +63,7 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Notifications />
|
||||
<AppBody />
|
||||
</BrowserRouter>
|
||||
</MantineProvider >
|
||||
|
||||
@ -21,7 +21,9 @@ const HostConfigPage = () => {
|
||||
queryFn: async () => {
|
||||
const host = await frigateApi.getHost(id || '')
|
||||
const hostName = mapHostToHostname(host)
|
||||
if (hostName)
|
||||
return proxyApi.getHostConfigRaw(hostName)
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,22 +1,33 @@
|
||||
import { Flex, Grid, Group } from '@mantine/core';
|
||||
import HeadSearch from '../shared/components/inputs/HeadSearch';
|
||||
import ViewSelector, { SelectorViewState } from '../shared/components/TableGridViewSelector';
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { getCookie, setCookie } from 'cookies-next';
|
||||
import { Flex, Grid, Group, TextInput } from '@mantine/core';
|
||||
import { useContext, useState, useEffect, useMemo } from 'react';
|
||||
import { Context } from '..';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
||||
import RetryErrorPage from './RetryErrorPage';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { dimensions } from '../shared/dimensions/dimensions';
|
||||
import CameraCard from '../widgets/CameraCard';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema';
|
||||
|
||||
const MainPage = () => {
|
||||
const { sideBarsStore } = useContext(Context)
|
||||
const isMobile = useMediaQuery(dimensions.mobileSize)
|
||||
const [searchQuery, setSearchQuery] = useState<string>()
|
||||
const [filteredCameras, setFilteredCameras] = useState<GetCameraWHostWConfig[]>()
|
||||
|
||||
const { data: cameras, isPending, isError, refetch } = useQuery({
|
||||
queryKey: [frigateQueryKeys.getCamerasWHost],
|
||||
queryFn: frigateApi.getCamerasWHost
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery && cameras) {
|
||||
setFilteredCameras(cameras.filter(camera => camera.name.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
} else {
|
||||
setFilteredCameras(undefined)
|
||||
}
|
||||
}, [searchQuery, cameras])
|
||||
|
||||
useEffect(() => {
|
||||
sideBarsStore.rightVisible = false
|
||||
@ -24,30 +35,27 @@ const MainPage = () => {
|
||||
sideBarsStore.setRightChildren(null)
|
||||
}, [])
|
||||
|
||||
const [viewState, setTableState] = useState(getCookie('aps-main-view') as SelectorViewState || SelectorViewState.GRID)
|
||||
const handleToggleState = (state: SelectorViewState) => {
|
||||
setCookie('aps-main-view', state, { maxAge: 60 * 60 * 24 * 30 });
|
||||
setTableState(state)
|
||||
}
|
||||
|
||||
const { data: cameras, isPending, isError, refetch } = useQuery({
|
||||
queryKey: [frigateQueryKeys.getCamerasWHost],
|
||||
queryFn: frigateApi.getCamerasWHost
|
||||
})
|
||||
|
||||
if (isPending) return <CenterLoader />
|
||||
|
||||
if (isError) return <RetryErrorPage onRetry={refetch} />
|
||||
|
||||
const cards = () => {
|
||||
// return cameras.filter(cam => cam.frigateHost?.host.includes('5000')).slice(0, 25).map(camera => (
|
||||
return cameras.map(camera => (
|
||||
const cards = useMemo(() => {
|
||||
if (filteredCameras)
|
||||
return filteredCameras.map(camera => (
|
||||
<CameraCard
|
||||
key={camera.id}
|
||||
camera={camera}
|
||||
/>)
|
||||
)
|
||||
}
|
||||
else
|
||||
return cameras?.map(
|
||||
camera => (
|
||||
<CameraCard
|
||||
key={camera.id}
|
||||
camera={camera}
|
||||
/>)
|
||||
)
|
||||
}, [cameras, filteredCameras, searchQuery])
|
||||
|
||||
if (isPending) return <CenterLoader />
|
||||
|
||||
if (isError) return <RetryErrorPage onRetry={refetch} />
|
||||
|
||||
return (
|
||||
<Flex direction='column' h='100%' w='100%' >
|
||||
@ -56,12 +64,20 @@ const MainPage = () => {
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
><HeadSearch /></Flex>
|
||||
{/* <ViewSelector state={viewState} onChange={handleToggleState} /> */}
|
||||
>
|
||||
<TextInput
|
||||
maw={400}
|
||||
style={{ flexGrow: 1 }}
|
||||
placeholder="Search..."
|
||||
icon={<IconSearch size="0.9rem" stroke={1.5} />}
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex justify='center' h='100%' direction='column' w='100%' >
|
||||
<Grid mt='sm' justify="center" mb='sm' align='stretch'>
|
||||
{cards()}
|
||||
{cards}
|
||||
</Grid>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@ -1,18 +1,14 @@
|
||||
|
||||
import { useState, useContext, useEffect, lazy, Suspense } from 'react';
|
||||
import { Accordion, Flex, Text } from '@mantine/core';
|
||||
import { useState, useContext, useEffect } from 'react';
|
||||
import { Flex, Text } from '@mantine/core';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Context } from '..';
|
||||
import RecordingsFiltersRightSide from '../widgets/RecordingsFiltersRightSide';
|
||||
import SelectedCameraList from '../widgets/SelectedCameraList';
|
||||
import SelectedHostList from '../widgets/SelectedHostList';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 SelectedDayList from '../widgets/SelectedDayList';
|
||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||
|
||||
|
||||
@ -102,7 +98,7 @@ const RecordingsPage = observer(() => {
|
||||
const [startDay, endDay] = period
|
||||
if (startDay && endDay) {
|
||||
if (startDay.getDate() === endDay.getDate()) { // if select only one day
|
||||
return <SelecteDayList day={startDay} />
|
||||
return <SelectedDayList day={startDay} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { z } from "zod"
|
||||
import {
|
||||
GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost,
|
||||
GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig, GetRole,
|
||||
GetUserByRole, GetRoleWCameras
|
||||
GetUserByRole, GetRoleWCameras, GetExportedFile
|
||||
} from "./frigate.schema";
|
||||
import { FrigateConfig } from "../../types/frigateConfig";
|
||||
import { url } from "inspector";
|
||||
@ -46,15 +46,28 @@ export const frigateApi = {
|
||||
}).then(res => res.data)
|
||||
}
|
||||
|
||||
export const proxyPrefix = `${proxyURL.protocol}//${proxyURL.host}/proxy/`
|
||||
|
||||
export const proxyApi = {
|
||||
getHostConfigRaw: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config/raw`).then(res => res.data),
|
||||
getHostConfig: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config`).then(res => res.data),
|
||||
getImageFrigate: async (imageUrl: string) => {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.blob();
|
||||
const response = await axios.get<Blob>(imageUrl, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getVideoFrigate: async (videoUrl: string, onProgress: (percentage: number | undefined) => void) => {
|
||||
const response = await axios.get<Blob>(videoUrl, {
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
const total = progressEvent.total
|
||||
const current = progressEvent.loaded;
|
||||
const percentage = total ? (current / total) * 100 : undefined
|
||||
onProgress(percentage);
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getHostRestart: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/restart`).then(res => res.data),
|
||||
|
||||
@ -108,23 +121,35 @@ export const proxyApi = {
|
||||
cameraWsURL: (hostName: string, cameraName: string) =>
|
||||
`ws://${proxyURL.host}/proxy-ws/${hostName}/live/jsmpeg/${cameraName}`,
|
||||
cameraImageURL: (hostName: string, cameraName: string) =>
|
||||
`${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/api/${cameraName}/latest.jpg`,
|
||||
`${proxyPrefix}${hostName}/api/${cameraName}/latest.jpg`,
|
||||
eventURL: (hostName: string, event: string) =>
|
||||
`${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/vod/event/${event}/master.m3u8`,
|
||||
`${proxyPrefix}${hostName}/vod/event/${event}/master.m3u8`,
|
||||
eventThumbnailUrl: (hostName: string, eventId: string) => `${proxyPrefix}${hostName}/api/events/${eventId}/thumbnail.jpg`,
|
||||
eventDownloadURL: (hostName: string, eventId: string) => `${proxyPrefix}${hostName}/api/events/${eventId}/clip.mp4?download=true`,
|
||||
// http://127.0.0.1:5000/vod/2024-02/23/19/CameraName/Asia,Krasnoyarsk/master.m3u8
|
||||
recordingURL: (hostName: string, cameraName: string, timezone: string, day: string, hour: string) => {// day:2024-02-23 hour:19
|
||||
const parts = day.split('-')
|
||||
const date = `${parts[0]}-${parts[1]}/${parts[2]}/${hour}`
|
||||
return `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/vod/${date}/${cameraName}/${timezone}/master.m3u8`
|
||||
return `${proxyPrefix}${hostName}/vod/${date}/${cameraName}/${timezone}/master.m3u8`
|
||||
},
|
||||
// linkURL: (hostName: string, link: string) => `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}${link}`, TODO delete
|
||||
postExportVideoTask: (hostName: string, cameraName: string, startUnixTime: number, endUnixTime: number) => {
|
||||
const url = `proxy/${hostName}/api/export/${cameraName}/start/${startUnixTime}/end/${endUnixTime}`
|
||||
return instanceApi.post(url, { playback: 'realtime' }).then(res => res.data) // Payload: {"playback":"realtime"}
|
||||
},
|
||||
getExportedVideoList: (hostName: string) => instanceApi.get<GetExportedFile[]>(`proxy/${hostName}/exports/`).then(res => res.data),
|
||||
getVideoUrl: (hostName: string, fileName: string) => `${proxyPrefix}${hostName}/exports/${fileName}`,
|
||||
deleteExportedVideo: (hostName: string, videoName: string) => instanceApi.delete(`proxy/${hostName}/api/export/${videoName}`).then(res => res.data)
|
||||
|
||||
// filename example Home_1_Backyard_2024_02_26_16_25__2024_02_26_16_26.mp4
|
||||
|
||||
}
|
||||
|
||||
export const mapCamerasFromConfig = (config: FrigateConfig): string[] => {
|
||||
return Object.keys(config.cameras)
|
||||
}
|
||||
|
||||
export const mapHostToHostname = (host: GetFrigateHost): string => {
|
||||
export const mapHostToHostname = (host?: GetFrigateHost): string | undefined => {
|
||||
if (!host) return
|
||||
const url = new URL(host.host)
|
||||
const hostName = url.host
|
||||
return hostName
|
||||
|
||||
@ -98,6 +98,13 @@ export const getUserByRoleSchema = z.object({
|
||||
email: z.string(),
|
||||
})
|
||||
|
||||
export const getExpotedFile = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
mtime: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
|
||||
export type GetConfig = z.infer<typeof getConfigSchema>
|
||||
export type PutConfig = z.infer<typeof putConfigSchema>
|
||||
export type GetFrigateHost = z.infer<typeof getFrigateHostSchema>
|
||||
@ -110,3 +117,4 @@ export type DeleteFrigateHost = z.infer<typeof deleteFrigateHostSchema>
|
||||
export type GetRole = z.infer<typeof getRoleSchema>
|
||||
export type GetRoleWCameras = z.infer<typeof getRoleWCamerasSchema>
|
||||
export type GetUserByRole = z.infer<typeof getUserByRoleSchema>
|
||||
export type GetExportedFile = z.infer<typeof getExpotedFile>
|
||||
@ -24,12 +24,12 @@ const CameraAccordion = ({
|
||||
|
||||
const camera = recStore.openedCamera || recStore.filteredCamera
|
||||
const host = recStore.filteredHost
|
||||
const hostName = mapHostToHostname(host)
|
||||
|
||||
const { data, isPending, isError, refetch } = useQuery({
|
||||
queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id],
|
||||
queryFn: () => {
|
||||
if (camera && host) {
|
||||
const hostName = mapHostToHostname(host)
|
||||
if (camera && hostName) {
|
||||
return proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
|
||||
}
|
||||
return null
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Accordion, Center, Flex, Group, NavLink, Text, UnstyledButton } from '@mantine/core';
|
||||
import { Accordion, Center, Flex, Group, NavLink, Progress, Text, UnstyledButton } from '@mantine/core';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { RecordSummary } from '../../../types/record';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
@ -6,7 +6,7 @@ import PlayControl from '../buttons/PlayControl';
|
||||
import { mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
||||
import { Context } from '../../..';
|
||||
import VideoPlayer from '../players/VideoPlayer';
|
||||
import { getResolvedTimeZone } from '../../utils/dateUtil';
|
||||
import { getResolvedTimeZone, mapDateHourToUnixTime } from '../../utils/dateUtil';
|
||||
import DayEventsAccordion from './DayEventsAccordion';
|
||||
import { strings } from '../../strings/strings';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -14,6 +14,7 @@ import AccordionControlButton from '../buttons/AccordionControlButton';
|
||||
import { IconExternalLink, IconShare } from '@tabler/icons-react';
|
||||
import { routesPath } from '../../../router/routes.path';
|
||||
import AccordionShareButton from '../buttons/AccordionShareButton';
|
||||
import VideoDownloader from '../../../widgets/VideoDownloader';
|
||||
|
||||
interface RecordingAccordionProps {
|
||||
recordSummary?: RecordSummary
|
||||
@ -26,14 +27,14 @@ const DayAccordion = ({
|
||||
const [playedValue, setVideoPlayerState] = useState<string>()
|
||||
const [openedValue, setOpenedValue] = useState<string>()
|
||||
const [playerUrl, setPlayerUrl] = useState<string>()
|
||||
const [link, setLink] = useState<string>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const camera = recStore.openedCamera || recStore.filteredCamera
|
||||
const hostName = mapHostToHostname(recStore.filteredHost)
|
||||
|
||||
const createRecordURL = (recordId: string): string | undefined => {
|
||||
const record = {
|
||||
hostName: recStore.filteredHost ? mapHostToHostname(recStore.filteredHost) : '',
|
||||
hostName: hostName ? hostName : '',
|
||||
cameraName: camera?.name,
|
||||
day: recordSummary?.day,
|
||||
hour: recordId,
|
||||
@ -119,7 +120,7 @@ const DayAccordion = ({
|
||||
<Flex justify='space-between'>
|
||||
{hourLabel(hour.hour, hour.events)}
|
||||
<Group>
|
||||
<AccordionShareButton recordUrl={createRecordURL(hour.hour)}/>
|
||||
<AccordionShareButton recordUrl={createRecordURL(hour.hour)} />
|
||||
<AccordionControlButton onClick={() => hanleOpenNewLink(hour.hour)}>
|
||||
<IconExternalLink />
|
||||
</AccordionControlButton>
|
||||
@ -129,10 +130,20 @@ const DayAccordion = ({
|
||||
onClick={handleOpenPlayer} />
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel key={hour.hour + 'Panel'}>
|
||||
{playedValue === hour.hour && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
|
||||
{ }
|
||||
{recStore.filteredHost && camera && hostName ?
|
||||
<Flex w='100%' justify='center' mb='0.5rem'>
|
||||
<VideoDownloader
|
||||
cameraName={camera.name}
|
||||
hostName={hostName}
|
||||
startUnixTime={mapDateHourToUnixTime(recordSummary.day, hour.hour)[0]}
|
||||
endUnixTime={mapDateHourToUnixTime(recordSummary.day, hour.hour)[1]}
|
||||
/>
|
||||
</Flex>
|
||||
: ''}
|
||||
{hour.events > 0 ?
|
||||
<DayEventsAccordion day={recordSummary.day} hour={hour.hour} qty={hour.events} />
|
||||
:
|
||||
@ -141,7 +152,9 @@ const DayAccordion = ({
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
|
||||
</Accordion>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
58
src/shared/components/accordion/EventPanel.tsx
Normal file
58
src/shared/components/accordion/EventPanel.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Flex, Group, Button, Text, Image } from '@mantine/core';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import { proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
||||
import { strings } from '../../strings/strings';
|
||||
import { unixTimeToDate, getDurationFromTimestamps } from '../../utils/dateUtil';
|
||||
import VideoPlayer from '../players/VideoPlayer';
|
||||
import { EventFrigate } from '../../../types/event';
|
||||
|
||||
interface EventPanelProps {
|
||||
event: EventFrigate
|
||||
playedValue?: string
|
||||
playerUrl?: string
|
||||
hostName?: string
|
||||
}
|
||||
|
||||
const EventPanel = ({
|
||||
event,
|
||||
playedValue,
|
||||
playerUrl,
|
||||
hostName,
|
||||
}: EventPanelProps) => {
|
||||
return (
|
||||
<>
|
||||
{playedValue === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
|
||||
<Flex w='100%' justify='space-between'>
|
||||
{!hostName ? <></> :
|
||||
<Image
|
||||
maw={200}
|
||||
fit="contain"
|
||||
withPlaceholder
|
||||
src={proxyApi.eventThumbnailUrl(hostName, event.id)} />
|
||||
}
|
||||
<Flex direction='column' align='end' justify='center'>
|
||||
{!hostName ? '' :
|
||||
<Flex>
|
||||
<Button
|
||||
component="a"
|
||||
href={proxyApi.eventDownloadURL(hostName, event.id)}
|
||||
download
|
||||
variant="outline"
|
||||
leftIcon={<IconExternalLink size="0.9rem" />}>
|
||||
Download event
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
<Text mt='1rem'>{strings.camera}: {event.camera}</Text>
|
||||
<Text>{strings.player.object}: {event.label}</Text>
|
||||
<Text>{strings.player.startTime}: {unixTimeToDate(event.start_time)}</Text>
|
||||
<Text>{strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)}</Text>
|
||||
<Text>{strings.player.rating}: {(event.data.score * 100).toFixed(2)}%</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventPanel;
|
||||
@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
|
||||
import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema';
|
||||
import PlayControl from '../buttons/PlayControl';
|
||||
import VideoPlayer from '../players/VideoPlayer';
|
||||
import { getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../../utils/dateUtil';
|
||||
import RetryError from '../RetryError';
|
||||
import { strings } from '../../strings/strings';
|
||||
@ -16,6 +15,7 @@ import { routesPath } from '../../../router/routes.path';
|
||||
import AccordionControlButton from '../buttons/AccordionControlButton';
|
||||
import AccordionShareButton from '../buttons/AccordionShareButton';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import EventPanel from './EventPanel';
|
||||
|
||||
/**
|
||||
* @param day frigate format, e.g day: 2024-02-23
|
||||
@ -47,18 +47,19 @@ const EventsAccordion = observer(({
|
||||
const [playerUrl, setPlayerUrl] = useState<string>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const inHost = recStore.filteredHost
|
||||
const inCamera = recStore.openedCamera || recStore.filteredCamera
|
||||
const isRequiredParams = inHost && inCamera
|
||||
const host = recStore.filteredHost
|
||||
const hostName = mapHostToHostname(host)
|
||||
const camera = recStore.openedCamera || recStore.filteredCamera
|
||||
const isRequiredParams = host && camera
|
||||
|
||||
const { data, isPending, isError, refetch } = useQuery({
|
||||
queryKey: [frigateQueryKeys.getEvents, inHost, inCamera, day, hour],
|
||||
queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour],
|
||||
queryFn: () => {
|
||||
if (!isRequiredParams) return null
|
||||
const [startTime, endTime] = getUnixTime(day, hour)
|
||||
const parsed = getEventsQuerySchema.safeParse({
|
||||
hostName: mapHostToHostname(inHost),
|
||||
camerasName: [inCamera.name],
|
||||
hostName: mapHostToHostname(host),
|
||||
camerasName: [camera.name],
|
||||
after: startTime,
|
||||
before: endTime,
|
||||
hasClip: true,
|
||||
@ -84,15 +85,15 @@ const EventsAccordion = observer(({
|
||||
})
|
||||
|
||||
const createEventUrl = (eventId: string) => {
|
||||
if (inHost)
|
||||
return proxyApi.eventURL(mapHostToHostname(inHost), eventId)
|
||||
if (hostName)
|
||||
return proxyApi.eventURL(hostName, eventId)
|
||||
return undefined
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (playedValue) {
|
||||
// console.log('openVideoPlayer', playedValue)
|
||||
if (playedValue && inHost) {
|
||||
if (playedValue && host) {
|
||||
const url = createEventUrl(playedValue)
|
||||
console.log('GET EVENT URL: ', url)
|
||||
setPlayerUrl(url)
|
||||
@ -173,15 +174,11 @@ const EventsAccordion = observer(({
|
||||
</Flex>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel key={event.id + 'Panel'}>
|
||||
{playedValue === event.id && playerUrl ? <VideoPlayer videoUrl={playerUrl} /> : <></>}
|
||||
<Group mt='1rem'>
|
||||
<Text>{strings.camera}: {event.camera}</Text>
|
||||
<Text>{strings.player.object}: {event.label}</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>
|
||||
<EventPanel
|
||||
event={event}
|
||||
playedValue={playedValue}
|
||||
playerUrl={playerUrl}
|
||||
hostName={hostName} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CameraConfig } from "../../../types/frigateConfig";
|
||||
import { Flex, Text, Image } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api";
|
||||
import { useIntersection } from "@mantine/hooks";
|
||||
import CogwheelLoader from "../loaders/CogwheelLoader";
|
||||
import RetryError from "../RetryError";
|
||||
|
||||
interface AutoUpdatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
className?: string;
|
||||
@ -21,9 +22,10 @@ const AutoUpdatedImage = ({
|
||||
}: AutoUpdatedImageProps) => {
|
||||
const { ref, entry } = useIntersection({ threshold: 0.1, })
|
||||
const isVisible = entry?.isIntersecting
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
|
||||
const { data: imageBlob, refetch, isPending, isError } = useQuery({
|
||||
queryKey: ['image', imageUrl],
|
||||
queryKey: [imageUrl],
|
||||
queryFn: () => proxyApi.getImageFrigate(imageUrl),
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: Infinity,
|
||||
@ -33,27 +35,38 @@ const AutoUpdatedImage = ({
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const intervalId = setInterval(() => {
|
||||
refetch();
|
||||
}, 60 * 1000);
|
||||
refetch()
|
||||
}, 60 * 1000)
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [refetch, isVisible]);
|
||||
}, [refetch, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageBlob && imageBlob instanceof Blob) {
|
||||
const objectURL = URL.createObjectURL(imageBlob);
|
||||
setImageSrc(objectURL);
|
||||
|
||||
return () => {
|
||||
if (objectURL) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [imageBlob])
|
||||
|
||||
if (isPending) return <CogwheelLoader />
|
||||
|
||||
if (isError) return (
|
||||
<Flex direction="column" justify="center" h="100%">
|
||||
<Text align="center">Error loading!</Text>
|
||||
<RetryError onRetry={refetch}/>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob)
|
||||
|
||||
const image = URL.createObjectURL(imageBlob!)
|
||||
if (!imageSrc) return null
|
||||
|
||||
return (
|
||||
<Flex direction="column" justify="center" h="100%">
|
||||
{enabled ? <Image ref={ref} src={image} alt="Dynamic Content" {...rest} />
|
||||
{enabled ? <Image ref={ref} src={imageSrc} alt="Dynamic Content" {...rest} />
|
||||
:
|
||||
<Text align="center">Camera is disabled in config, no stream or snapshot available!</Text>
|
||||
}
|
||||
|
||||
@ -1,25 +1,22 @@
|
||||
import { Flex, TextInput } from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import ViewSelector from '../TableGridViewSelector';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface HeadSearchProps {
|
||||
search?: string
|
||||
handleSearchChange?(): void
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
const HeadSearch = ({ search, handleSearchChange }: HeadSearchProps) => {
|
||||
const HeadSearch = ({ search, onChange }: HeadSearchProps) => {
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
maw={400}
|
||||
style={{ flexGrow: 1 }}
|
||||
placeholder="Search..."
|
||||
icon={<IconSearch size="0.9rem" stroke={1.5} />}
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -31,7 +31,8 @@ const HostSettingsMenu = ({ host }: HostSettingsMenuProps) => {
|
||||
}
|
||||
|
||||
const handleRestart = () => {
|
||||
mutation.mutate(mapHostToHostname(host))
|
||||
const hostName = mapHostToHostname(host)
|
||||
if (hostName) mutation.mutate(hostName)
|
||||
}
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
|
||||
@ -13,6 +13,7 @@ export const strings = {
|
||||
startTime: 'Начало',
|
||||
endTime: 'Конец',
|
||||
doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра',
|
||||
rating: 'Рейтинг',
|
||||
},
|
||||
|
||||
empty: 'Пусто',
|
||||
|
||||
@ -4,6 +4,41 @@ export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||
export const epochToLong = (date: number): number => date / 1000;
|
||||
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||
|
||||
|
||||
export const formatFileTimestamps = (startUnixTime: number, endUnixTime: number, cameraName: string) => {
|
||||
const formatTime = (time: number) => {
|
||||
const date = new Date(time * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${year}_${month}_${day}_${hours}_${minutes}`;
|
||||
};
|
||||
|
||||
const startTimeFormatted = formatTime(startUnixTime);
|
||||
const endTimeFormatted = formatTime(endUnixTime);
|
||||
const fileName = `${cameraName}_${startTimeFormatted}__${endTimeFormatted}.mp4`;
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param date e.g. "2024-02-27"
|
||||
* @param hour e.g. "20"
|
||||
* @returns [startUnixTime, endUnixTime]
|
||||
*/
|
||||
export const mapDateHourToUnixTime = (date: string, hour: string) => {
|
||||
const startDateTime = new Date(`${date}T${hour}:00:00`)
|
||||
const startUnixTime = startDateTime.getTime() / 1000
|
||||
const endDateTime = new Date(startDateTime);
|
||||
endDateTime.setMinutes(59)
|
||||
endDateTime.setSeconds(59)
|
||||
const endUnixTime = endDateTime.getTime() / 1000
|
||||
return [startUnixTime, endUnixTime]
|
||||
}
|
||||
|
||||
const getDateTimeYesterday = (dateTime: Date): Date => {
|
||||
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||
|
||||
@ -44,7 +44,8 @@ const CameraCard = ({
|
||||
}: CameraCardProps) => {
|
||||
const { classes } = useStyles();
|
||||
const navigate = useNavigate()
|
||||
const imageUrl = camera.frigateHost ? proxyApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras
|
||||
const hostName = mapHostToHostname(camera.frigateHost)
|
||||
const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras
|
||||
|
||||
const handleOpenLiveView = () => {
|
||||
const url = routesPath.LIVE_PATH.replace(':id', camera.id)
|
||||
|
||||
@ -9,14 +9,14 @@ import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import DayAccordion from '../shared/components/accordion/DayAccordion';
|
||||
|
||||
interface SelecteDayListProps {
|
||||
interface SelectedDayListProps {
|
||||
day: Date
|
||||
|
||||
}
|
||||
|
||||
const SelecteDayList = ({
|
||||
const SelectedDayList = ({
|
||||
day
|
||||
}: SelecteDayListProps) => {
|
||||
}: SelectedDayListProps) => {
|
||||
const { recordingsStore: recStore } = useContext(Context)
|
||||
const camera = recStore.filteredCamera
|
||||
const host = recStore.filteredHost
|
||||
@ -27,9 +27,11 @@ const SelecteDayList = ({
|
||||
if (camera && host) {
|
||||
const stringDay = dateToQueryString(day)
|
||||
const hostName = mapHostToHostname(host)
|
||||
if (hostName){
|
||||
const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone())
|
||||
return res.find(record => record.day === stringDay)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
@ -50,4 +52,4 @@ const SelecteDayList = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(SelecteDayList);
|
||||
export default observer(SelectedDayList);
|
||||
165
src/widgets/VideoDownloader.tsx
Normal file
165
src/widgets/VideoDownloader.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { Button, Loader, Text, Notification, Progress } from '@mantine/core';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { frigateApi, proxyApi } from '../services/frigate.proxy/frigate.api';
|
||||
import { IconAlertCircle, IconExternalLink } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import RetryError from '../shared/components/RetryError';
|
||||
import { formatFileTimestamps, unixTimeToDate } from '../shared/utils/dateUtil';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
interface VideoDownloaderProps {
|
||||
cameraName: string
|
||||
hostName: string
|
||||
startUnixTime: number
|
||||
endUnixTime: number
|
||||
}
|
||||
|
||||
const VideoDownloader = ({
|
||||
cameraName,
|
||||
hostName,
|
||||
startUnixTime,
|
||||
endUnixTime,
|
||||
}: VideoDownloaderProps) => {
|
||||
const maxVideoTime = 70 * 60
|
||||
const [createName, setCreateName] = useState<string>()
|
||||
const [link, setLink] = useState<string>()
|
||||
const [error, setError] = useState<boolean>()
|
||||
const [timer, setTimer] = useState<NodeJS.Timer>()
|
||||
const [videoBlob, setVideoBlob] = useState<Blob>()
|
||||
const [videoSrc, setVideoSrc] = useState<string>()
|
||||
const [progress, setProgress] = useState<number>()
|
||||
|
||||
const createVideo = useMutation({
|
||||
mutationFn: () => {
|
||||
setError(false)
|
||||
return proxyApi.postExportVideoTask(hostName, cameraName, startUnixTime, endUnixTime)
|
||||
},
|
||||
onSuccess: () => {
|
||||
const fileName = formatFileTimestamps(startUnixTime, endUnixTime, cameraName)
|
||||
setCreateName(fileName)
|
||||
},
|
||||
onError: () => setError(true)
|
||||
})
|
||||
|
||||
const checkVideo = useMutation({ mutationFn: () => proxyApi.getExportedVideoList(hostName) })
|
||||
|
||||
const getVideBlob = useMutation({
|
||||
mutationKey: [link],
|
||||
mutationFn: (inlink: string) => {
|
||||
return proxyApi.getVideoFrigate(inlink, (progress) => {
|
||||
setProgress(progress)
|
||||
})
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setVideoBlob(data)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteVideo = useMutation({
|
||||
mutationFn: (videoName: string) => proxyApi.deleteExportedVideo(hostName, videoName),
|
||||
onSuccess: () => setLink(undefined)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (createName && !link && !videoBlob) {
|
||||
const intervalId = setInterval(() => {
|
||||
checkVideo.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
const createdFile = data.find(file => file.name === createName);
|
||||
if (createdFile && !link && !videoBlob) {
|
||||
const link = proxyApi.getVideoUrl(hostName, createdFile.name)
|
||||
setLink(link);
|
||||
getVideBlob.mutateAsync(link)
|
||||
clearInterval(intervalId)
|
||||
setTimer(undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 5 * 1000)
|
||||
|
||||
if (intervalId) setTimer(intervalId)
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(timer)
|
||||
setTimer(undefined)
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
}, [createName, link, videoBlob])
|
||||
|
||||
useEffect(() => {
|
||||
if (videoBlob && videoBlob instanceof Blob && createName) {
|
||||
if (link) deleteVideo.mutateAsync(createName)
|
||||
const objectURL = URL.createObjectURL(videoBlob);
|
||||
setVideoSrc(objectURL);
|
||||
return () => {
|
||||
if (objectURL) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [videoBlob, createName, link])
|
||||
|
||||
const checkTime = () => {
|
||||
const duration = endUnixTime - startUnixTime
|
||||
if (duration > maxVideoTime) {
|
||||
notifications.show({
|
||||
id: 'too-much-time',
|
||||
withCloseButton: true,
|
||||
onClose: () => console.log('unmounted'),
|
||||
onOpen: () => console.log('mounted'),
|
||||
autoClose: 5000,
|
||||
title: "Max duration",
|
||||
message: `Time can not be higher than ${maxVideoTime / 60} hour`,
|
||||
color: 'red',
|
||||
icon: <IconAlertCircle />,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!checkTime()) return
|
||||
createVideo.mutate()
|
||||
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
clearTimeout(timer)
|
||||
setTimer(undefined)
|
||||
setCreateName(undefined)
|
||||
setLink(undefined)
|
||||
}
|
||||
|
||||
|
||||
if (startUnixTime === 0 || endUnixTime === 0) return null
|
||||
if (error) return <RetryError onRetry={() => createVideo.mutate()} />
|
||||
if (link && progress && !videoSrc) return (
|
||||
<Progress w='100%' value={progress} size='xl' radius='xl' label={`${progress.toFixed(2)}%`} />
|
||||
)
|
||||
|
||||
const getReadyDownload = (
|
||||
// link example http://my-proxy-server/hostname:4000/exports/HOME_1_Backyard_2024_02_26_16_25__2024_02_26_16_26.mp4
|
||||
<Button component="a" href={videoSrc} download variant="outline" leftIcon={<IconExternalLink size="0.9rem" />}>
|
||||
Ready! Download?
|
||||
</Button>
|
||||
)
|
||||
if (videoSrc) return getReadyDownload
|
||||
|
||||
const preparingVideo = (
|
||||
<>
|
||||
<Text>Preparing video...<Loader /></Text>
|
||||
</>
|
||||
)
|
||||
if (createName) return preparingVideo
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoDownloader;
|
||||
30
yarn.lock
30
yarn.lock
@ -1140,7 +1140,7 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.11"
|
||||
|
||||
"@babel/runtime@^7.5.5":
|
||||
"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
|
||||
version "7.23.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
|
||||
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
|
||||
@ -1802,6 +1802,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.16.tgz#507c322347659424a915ec6b541d848f53abcf6a"
|
||||
integrity sha512-DnfMYSTSjYxbQJ80TzKHO5gRXGTIQKxBnRQVc+n4RANTwgWMiwEmxIwqRjbulfLzIhEvplskhqGgElunIAfw7g==
|
||||
|
||||
"@mantine/notifications@6.0.16":
|
||||
version "6.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-6.0.16.tgz#e3259f9bea564ae58d34810096b9288be47ca815"
|
||||
integrity sha512-KqlPW51sxgQoJmIC2lEWMVlwPqy04D35iRMkCSget8aNgzk0K5csJppXo6qwMFn2GHKVGXFKJMBUp06IXQbiig==
|
||||
dependencies:
|
||||
"@mantine/utils" "6.0.16"
|
||||
react-transition-group "4.4.2"
|
||||
|
||||
"@mantine/styles@6.0.16":
|
||||
version "6.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.16.tgz#625e5be80fc964fa6634f10e798c19f5a4c265ce"
|
||||
@ -4327,6 +4335,14 @@ dom-converter@^0.2.0:
|
||||
dependencies:
|
||||
utila "~0.4"
|
||||
|
||||
dom-helpers@^5.0.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
|
||||
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.8.7"
|
||||
csstype "^3.0.2"
|
||||
|
||||
dom-serializer@0:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
|
||||
@ -8204,7 +8220,7 @@ prompts@^2.0.1, prompts@^2.4.2:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@ -8481,6 +8497,16 @@ react-textarea-autosize@8.3.4:
|
||||
use-composed-ref "^1.3.0"
|
||||
use-latest "^1.2.1"
|
||||
|
||||
react-transition-group@4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
|
||||
integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.5.5"
|
||||
dom-helpers "^5.0.1"
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-use-websocket@^4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.7.0.tgz#e45545ed48eb60171bf6401d1884cc80c700a0ea"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user