add edit camera page
This commit is contained in:
parent
6de8e3ecd6
commit
72139e4519
@ -37,6 +37,7 @@
|
||||
"idb-keyval": "^6.2.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"keycloak-js": "^24.0.1",
|
||||
"konva": "^9.3.6",
|
||||
"mantine-react-table": "^1.0.0-beta.25",
|
||||
"mobx": "^6.9.0",
|
||||
"mobx-react-lite": "^3.4.3",
|
||||
@ -47,6 +48,7 @@
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^6.14.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-use-websocket": "^4.7.0",
|
||||
|
||||
@ -9,7 +9,6 @@ export const useRealmAccessRoles = () => {
|
||||
useEffect(() => {
|
||||
const updateRoles = () => {
|
||||
const tokenRoles = keycloak.tokenParsed?.realm_access?.roles;
|
||||
if (!isProduction) console.log(`tokenRoles:`, tokenRoles);
|
||||
if (tokenRoles) {
|
||||
setRoles(tokenRoles);
|
||||
} else {
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
const en = {
|
||||
editCameraPage: {
|
||||
notFrigateCamera: 'Not frigate camera',
|
||||
errorAtPut: 'Error at sending mask',
|
||||
cameraIdNotExist: 'Camera id does not exist',
|
||||
cameraConfigNotExist: 'Camera config does not exist',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
points: 'Points',
|
||||
},
|
||||
frigateConfigPage: {
|
||||
copyConfig: 'Copy Config',
|
||||
saveOnly: 'Save Only',
|
||||
saveAndRestart: 'Save & Restart',
|
||||
editorNotExist: 'Editor does not exists',
|
||||
},
|
||||
systemPage: {
|
||||
cameraStats: 'Cameras stats',
|
||||
storageStats: 'Storages stats',
|
||||
@ -56,7 +71,9 @@ const en = {
|
||||
doubleClickToFullHint: 'Double click for fullscreen',
|
||||
rating: 'Rating',
|
||||
},
|
||||
|
||||
config: 'Config',
|
||||
clear: 'Clear',
|
||||
edit: 'Edit',
|
||||
version: 'Version',
|
||||
uptime: 'Uptime',
|
||||
pleaseSelectRole: 'Please select Role',
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
const ru = {
|
||||
editCameraPage: {
|
||||
notFrigateCamera: 'Не камера Фригата',
|
||||
errorAtPut: 'Ошибка при отправке маски',
|
||||
cameraIdNotExist: 'ID камеры не найдено',
|
||||
cameraConfigNotExist: 'Конфиг. камеры не найден',
|
||||
width: 'Ширина',
|
||||
height: 'Высота',
|
||||
points: 'Точки',
|
||||
},
|
||||
frigateConfigPage: {
|
||||
copyConfig: 'Копировать Конфиг.',
|
||||
saveOnly: 'Только Сохранить',
|
||||
saveAndRestart: 'Сохранить & Перезагрузить',
|
||||
editorNotExist: 'Редактор не найден',
|
||||
},
|
||||
systemPage: {
|
||||
cameraStats: 'Статистика Камер',
|
||||
storageStats: 'Статистика Хранения',
|
||||
@ -56,6 +71,9 @@ const ru = {
|
||||
doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра',
|
||||
rating: 'Рейтинг',
|
||||
},
|
||||
config: 'Конфиг.',
|
||||
clear: 'Очистить',
|
||||
edit: 'Изменить',
|
||||
version: 'Версия',
|
||||
uptime: 'Время работы',
|
||||
pleaseSelectRole: 'Пожалуйста выберите роль',
|
||||
|
||||
194
src/pages/EditCameraPage.tsx
Normal file
194
src/pages/EditCameraPage.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { Button, Center, Flex, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Context } from '..';
|
||||
import { useAdminRole } from '../hooks/useAdminRole';
|
||||
import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api';
|
||||
import MaskSelect, { MaskItem, MaskType } from '../shared/components/filters/MaskSelect';
|
||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||
import { Point, extractMaskNumber } from '../shared/utils/maskPoint';
|
||||
import CameraMaskDrawer from '../widgets/CameraMaskDrawer';
|
||||
import CameraPageHeader from '../widgets/header/CameraPageHeader';
|
||||
import Forbidden from './403';
|
||||
import RetryErrorPage from './RetryErrorPage';
|
||||
|
||||
const EditCameraPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const executed = useRef(false)
|
||||
let { id: cameraId } = useParams<'id'>()
|
||||
if (!cameraId) throw Error(t('editCameraPage.cameraIdNotExist'))
|
||||
const [selectedMask, setSelectedMask] = useState<MaskItem>()
|
||||
const [points, setPoints] = useState<Point[]>()
|
||||
|
||||
const { data: camera, isPending, isError, refetch } = useQuery({
|
||||
queryKey: [frigateQueryKeys.getCameraWHost, cameraId],
|
||||
queryFn: () => frigateApi.getCameraWHost(cameraId!)
|
||||
})
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedMask || !points || !camera) return Promise.reject(t('editCameraPage.errorAtPut'))
|
||||
if (points.length < 3) return Promise.reject(t('editCameraPage.errorAtPut'))
|
||||
const hostName = mapHostToHostname(camera.frigateHost)
|
||||
if (!hostName) return Promise.reject(t('editCameraPage.errorAtPut'))
|
||||
switch (selectedMask.type) {
|
||||
case MaskType.Motion: {
|
||||
const index = extractMaskNumber(selectedMask.id)
|
||||
if (index === null) return Promise.reject(t('editCameraPage.errorAtPut'))
|
||||
return proxyApi.putMotionMask(hostName, camera.name, index, Point.arrayToRequest(points))
|
||||
.catch(error => {
|
||||
if (error.response && error.response.data) {
|
||||
return Promise.reject(error.response.data)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
case MaskType.Object: {
|
||||
let maskName = selectedMask.id
|
||||
if (selectedMask.id.startsWith('add_new_')) {
|
||||
maskName = selectedMask.id.replace('add_new_', '')
|
||||
}
|
||||
return proxyApi.putZoneMask(hostName, camera.name, maskName, Point.arrayToRequest(points))
|
||||
.catch(error => {
|
||||
if (error.response && error.response.data) {
|
||||
return Promise.reject(error.response.data)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
case MaskType.Zone: {
|
||||
const index = extractMaskNumber(selectedMask.id)
|
||||
if (index === null) return Promise.reject(t('editCameraPage.errorAtPut'))
|
||||
let maskName = selectedMask.id
|
||||
if (selectedMask.id.startsWith('add_new_')) {
|
||||
maskName = selectedMask.id.replace('add_new_', '')
|
||||
}
|
||||
maskName = maskName.split('_')[0]
|
||||
return proxyApi.putObjectMask(hostName, camera.name, maskName, index, Point.arrayToRequest(points))
|
||||
.catch(error => {
|
||||
if (error.response && error.response.data) {
|
||||
return Promise.reject(error.response.data)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
notifications.show({
|
||||
id: data?.message,
|
||||
withCloseButton: true,
|
||||
autoClose: 5000,
|
||||
title: `Sucess: ${data?.success}`,
|
||||
message: data?.message,
|
||||
color: 'green',
|
||||
icon: <IconCircleCheck />
|
||||
})
|
||||
},
|
||||
onError: (e) => {
|
||||
notifications.show({
|
||||
id: e.message,
|
||||
withCloseButton: true,
|
||||
autoClose: false,
|
||||
title: "Error",
|
||||
message: e.message,
|
||||
color: 'red',
|
||||
icon: <IconAlertCircle />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const { isAdmin, isLoading: adminLoading } = useAdminRole()
|
||||
|
||||
const { sideBarsStore } = useContext(Context)
|
||||
useEffect(() => {
|
||||
if (!executed.current) {
|
||||
sideBarsStore.rightVisible = false
|
||||
sideBarsStore.setLeftChildren(null)
|
||||
sideBarsStore.setRightChildren(null)
|
||||
executed.current = true
|
||||
}
|
||||
}, [sideBarsStore])
|
||||
|
||||
if (isPending || adminLoading) return <CenterLoader />
|
||||
if (!isAdmin) return <Forbidden />
|
||||
if (isError) return <RetryErrorPage onRetry={refetch} />
|
||||
|
||||
const hostName = mapHostToHostname(camera.frigateHost)
|
||||
|
||||
if (!hostName) return (
|
||||
<Center>
|
||||
<Text>{t('editCameraPage.notFrigateCamera')}</Text>
|
||||
</Center>
|
||||
)
|
||||
|
||||
if (!camera.config) return (
|
||||
<Center>
|
||||
<Text>{t('editCameraPage.cameraConfigNotExist')}</Text>
|
||||
</Center>
|
||||
)
|
||||
|
||||
const handleSelectMask = (mask?: MaskItem) => {
|
||||
setSelectedMask(mask)
|
||||
setPoints(mask?.coordinates)
|
||||
}
|
||||
|
||||
const handleChangePoints = (points: Point[]) => {
|
||||
setPoints(points)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedMask || !points) return
|
||||
console.log('type', selectedMask?.type)
|
||||
console.log('save', points)
|
||||
mutate()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setPoints(selectedMask?.coordinates)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex w='100%' h='100%' direction='column'>
|
||||
<CameraPageHeader camera={camera} configButton/>
|
||||
{!camera.config ? null :
|
||||
<Flex w='100%' justify='center' mb='1rem'>
|
||||
<MaskSelect
|
||||
miw='50%'
|
||||
cameraConfig={camera.config}
|
||||
onSelect={handleSelectMask}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
{!points ? null :
|
||||
<Flex justify='center' align='center' mb='1rem'>
|
||||
<Text mr='1rem'>{t('editCameraPage.points')}: {points.map(point => `(x: ${point.x}, y: ${point.y}) `)}</Text>
|
||||
<Flex>
|
||||
<Button onClick={handleSave} ml='0.5rem'>Save</Button>
|
||||
<Button onClick={handleReset} ml='0.5rem'>Reset</Button>
|
||||
<Button onClick={handleClear} ml='0.5rem'>Clear</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
{!points || !camera.config ? null :
|
||||
<CameraMaskDrawer
|
||||
cameraWidth={camera.config.detect.width}
|
||||
cameraHeight={camera.config.detect.height}
|
||||
imageUrl={proxyApi.cameraImageURL(hostName, camera.name)}
|
||||
inPoints={points}
|
||||
onChange={handleChangePoints} />
|
||||
}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(EditCameraPage);
|
||||
@ -5,8 +5,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import * as monaco from "monaco-editor";
|
||||
import { SchemasSettings, configureMonacoYaml } from 'monaco-yaml';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Context } from '..';
|
||||
import { useAdminRole } from '../hooks/useAdminRole';
|
||||
import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api';
|
||||
@ -18,6 +18,7 @@ import Forbidden from './403';
|
||||
import RetryErrorPage from './RetryErrorPage';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
window.MonacoEnvironment = {
|
||||
@ -31,7 +32,16 @@ window.MonacoEnvironment = {
|
||||
}
|
||||
}
|
||||
|
||||
export const hostConfigPageQuery = {
|
||||
searchWord: 'searchWord ',
|
||||
}
|
||||
|
||||
const HostConfigPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const queryParams = useMemo(() => {
|
||||
return new URLSearchParams(location.search);
|
||||
}, [location.search])
|
||||
const executed = useRef(false)
|
||||
const host = useRef<GetFrigateHost | undefined>()
|
||||
const { sideBarsStore } = useContext(Context)
|
||||
@ -119,6 +129,17 @@ const HostConfigPage = () => {
|
||||
const model = monaco.editor.createModel(config, 'yaml', monaco.Uri.parse('monaco-yaml.yaml'))
|
||||
editor.setModel(model)
|
||||
editorRef.current = editor;
|
||||
const paramSearchWord = queryParams.get(hostConfigPageQuery.searchWord)
|
||||
|
||||
if (paramSearchWord && model) {
|
||||
const matches = model.findMatches(paramSearchWord, true, false, true, null, true);
|
||||
|
||||
if (matches.length > 0) {
|
||||
const firstMatch = matches[0].range;
|
||||
editor.revealPositionInCenter({ lineNumber: firstMatch.startLineNumber, column: firstMatch.startColumn });
|
||||
editor.setPosition({ lineNumber: firstMatch.startLineNumber, column: firstMatch.startColumn });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyConfig = useCallback(async () => {
|
||||
@ -132,14 +153,14 @@ const HostConfigPage = () => {
|
||||
const onHandleSaveConfig = useCallback(
|
||||
async (saveOption: SaveOption) => {
|
||||
if (!editorRef.current) {
|
||||
throw Error('Editor does not exists')
|
||||
throw Error(t('frigateConfigPage.editorNotExist'))
|
||||
}
|
||||
if (!isProduction) console.log('saveOption', saveOption)
|
||||
if (!isProduction) console.log('editorRef.current', editorRef.current.getValue().slice(0, 50))
|
||||
saveConfig({ saveOption: saveOption, config: editorRef.current.getValue() })
|
||||
}, [editorRef])
|
||||
|
||||
if (configPending || adminLoading ) return <CenterLoader />
|
||||
if (configPending || adminLoading) return <CenterLoader />
|
||||
|
||||
if (configError) return <RetryErrorPage onRetry={refetch} />
|
||||
if (!isAdmin) return <Forbidden />
|
||||
@ -148,13 +169,13 @@ const HostConfigPage = () => {
|
||||
<Flex direction='column' h='100%' w='100%' justify='stretch'>
|
||||
<Flex w='100%' justify='center' wrap='nowrap'>
|
||||
<Button size="sm" onClick={handleCopyConfig}>
|
||||
Copy Config
|
||||
{t('frigateConfigPage.copyConfig')}
|
||||
</Button>
|
||||
<Button ml='1rem' size="sm" onClick={(_) => onHandleSaveConfig(SaveOption.SaveRestart)}>
|
||||
Save & Restart
|
||||
{t('frigateConfigPage.saveAndRestart')}
|
||||
</Button>
|
||||
<Button ml='1rem' size="sm" onClick={(_) => onHandleSaveConfig(SaveOption.SaveOnly)}>
|
||||
Save Only
|
||||
{t('frigateConfigPage.saveOnly')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex h='100%' mt='1rem'>
|
||||
|
||||
@ -1,21 +1,19 @@
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { Context } from '..';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
||||
import { Flex } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||
import RetryErrorPage from './RetryErrorPage';
|
||||
import Player from '../widgets/Player';
|
||||
import { Button, Flex, Text } from '@mantine/core';
|
||||
import { routesPath } from '../router/routes.path';
|
||||
import { recordingsPageQuery } from './RecordingsPage';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Context } from '..';
|
||||
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||
import Player from '../widgets/Player';
|
||||
import CameraPageHeader from '../widgets/header/CameraPageHeader';
|
||||
import RetryErrorPage from './RetryErrorPage';
|
||||
|
||||
const LiveCameraPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const executed = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
let { id: cameraId } = useParams<'id'>()
|
||||
if (!cameraId) throw Error('Camera id does not exist')
|
||||
|
||||
@ -39,21 +37,10 @@ const LiveCameraPage = () => {
|
||||
|
||||
if (isError) return <RetryErrorPage onRetry={refetch} />
|
||||
|
||||
const handleOpenRecordings = () => {
|
||||
if (camera.frigateHost) {
|
||||
const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost.id}&${recordingsPageQuery.cameraId}=${camera.id}`
|
||||
navigate(url)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex w='100%' h='100%' justify='center' align='center' direction='column'>
|
||||
<Flex w='100%' justify='center' align='baseline' mb='1rem'>
|
||||
<Text mr='1rem'>{t('camera')}: {camera.name} {camera.frigateHost ? `/ ${camera.frigateHost.name}` : ''}</Text>
|
||||
{!camera.frigateHost ? <></> :
|
||||
<Button onClick={handleOpenRecordings}>{t('recordings')}</Button>
|
||||
}
|
||||
</Flex>
|
||||
<CameraPageHeader camera={camera} editButton />
|
||||
<Player camera={camera} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@ -86,9 +86,7 @@ const MainPage = () => {
|
||||
<Flex direction='column' h='100%' w='100%' >
|
||||
<Flex justify='space-between' align='center' w='100%'>
|
||||
<Flex w='100%'
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
justify='center'
|
||||
>
|
||||
<ClearableTextInput
|
||||
clerable
|
||||
|
||||
@ -9,6 +9,7 @@ export const routesPath = {
|
||||
HOST_STORAGE_PATH: '/hosts/:id/storage',
|
||||
ACCESS_PATH: '/access',
|
||||
LIVE_PATH: '/cameras/:id/',
|
||||
EDIT_PATH: '/cameras/:id/edit',
|
||||
PLAYER_PATH: '/player',
|
||||
THANKS_PATH: '/thanks',
|
||||
USER_DETAILED_PATH: '/user',
|
||||
|
||||
@ -13,6 +13,7 @@ import LiveCameraPage from "../pages/LiveCameraPage";
|
||||
import RecordingsPage from "../pages/RecordingsPage";
|
||||
import AccessSettings from "../pages/AccessSettingsPage";
|
||||
import PlayRecordPage from "../pages/PlayRecordPage";
|
||||
import EditCameraPage from "../pages/EditCameraPage";
|
||||
|
||||
interface IRoute {
|
||||
path: string,
|
||||
@ -52,6 +53,10 @@ export const routes: IRoute[] = [
|
||||
path: routesPath.LIVE_PATH,
|
||||
component: <LiveCameraPage />,
|
||||
},
|
||||
{
|
||||
path: routesPath.EDIT_PATH,
|
||||
component: <EditCameraPage />,
|
||||
},
|
||||
{
|
||||
path: routesPath.PLAYER_PATH,
|
||||
component: <PlayRecordPage />,
|
||||
|
||||
@ -12,6 +12,7 @@ import { getResolvedTimeZone } from "../../shared/utils/dateUtil";
|
||||
import { FrigateStats, GetFfprobe, GetHostStorage, GetVaInfo } from "../../types/frigateStats";
|
||||
import { PostSaveConfig, SaveOption } from "../../types/saveConfig";
|
||||
import keycloak from "../keycloak-config";
|
||||
import { PutMask } from "../../types/mask";
|
||||
|
||||
const instanceApi = axios.create({
|
||||
baseURL: proxyURL.toString(),
|
||||
@ -184,6 +185,18 @@ export const proxyApi = {
|
||||
}
|
||||
}).then(res => res.data),
|
||||
getHostStorage: (hostName: string) => instanceApi.get<GetHostStorage>(`proxy/${hostName}/api/recordings/storage`).then(res => res.data),
|
||||
putMotionMask: (hostName: string, cameraName: string, index: number, points: string) =>
|
||||
instanceApi
|
||||
.put<PutMask>(`proxy/${hostName}/api/config/set?cameras.${cameraName}.motion.mask.${index}=${points}`) // points format: 257,21,255,43,21,48,21,25
|
||||
.then(res => res.data),
|
||||
putZoneMask: (hostName: string, cameraName: string, zoneName: string, points: string) =>
|
||||
instanceApi
|
||||
.put<PutMask>(`proxy/${hostName}/api/config/set?cameras.${cameraName}.zones.${zoneName}.coordinates=${points}`) // points format: 257,21,255,43,21,48,21,25
|
||||
.then(res => res.data),
|
||||
putObjectMask: (hostName: string, cameraName: string, filterName: string, index: number, points: string) =>
|
||||
instanceApi
|
||||
.put<PutMask>(`proxy/${hostName}/api/config/set?cameras.${cameraName}.objects.filters.${filterName}.mask.${index}=${points}`) // points format: 257,21,255,43,21,48,21,25
|
||||
.then(res => res.data),
|
||||
}
|
||||
|
||||
export const mapCamerasFromConfig = (config: FrigateConfig): string[] => {
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { Center, Loader, MantineStyleSystemProps, SpacingValue, SystemProp } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React, { useEffect } from 'react';
|
||||
import { frigateQueryKeys, frigateApi } from '../../../services/frigate.proxy/frigate.api';
|
||||
import { useEffect } from 'react';
|
||||
import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api';
|
||||
import RetryError from '../RetryError';
|
||||
import CogwheelLoader from '../loaders/CogwheelLoader';
|
||||
import OneSelectFilter, { OneSelectItem } from './OneSelectFilter';
|
||||
import { SystemProp, SpacingValue, MantineStyleSystemProps, Loader, Center } from '@mantine/core';
|
||||
|
||||
interface HostSelectProps extends MantineStyleSystemProps {
|
||||
label?: string
|
||||
|
||||
101
src/shared/components/filters/MaskSelect.tsx
Normal file
101
src/shared/components/filters/MaskSelect.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { CameraConfig } from '../../../types/frigateConfig'
|
||||
import { Point } from '../../utils/maskPoint'
|
||||
import { OneSelectItem } from './OneSelectFilter'
|
||||
import { Flex, Select, Button, MantineStyleSystemProps } from '@mantine/core'
|
||||
|
||||
interface MaskSelectProps extends MantineStyleSystemProps {
|
||||
cameraConfig: CameraConfig
|
||||
onSelect: (mask?: MaskItem) => void
|
||||
}
|
||||
|
||||
export interface MaskItem {
|
||||
id: string
|
||||
type: MaskType
|
||||
coordinates: Point[]
|
||||
}
|
||||
|
||||
export enum MaskType {
|
||||
Motion = 'motion',
|
||||
Zone = 'zone',
|
||||
Object = 'object',
|
||||
}
|
||||
|
||||
const createMaskSelectItems = (data: MaskItem[], groupLabel: string): OneSelectItem[] => {
|
||||
return data.map(({ id, coordinates }, index) => ({
|
||||
value: id,
|
||||
label: `${id} ${Point.arrayToString(coordinates)}`,
|
||||
group: `${groupLabel} masks`,
|
||||
}))
|
||||
}
|
||||
|
||||
const MaskSelect: React.FC<MaskSelectProps> = ({
|
||||
cameraConfig,
|
||||
onSelect,
|
||||
...styleProps
|
||||
}) => {
|
||||
|
||||
const { motions, zones, objects, items } = useMemo(() => {
|
||||
const motions: MaskItem[] = cameraConfig.motion.mask.map((mask, index) => ({
|
||||
id: `motion_${index}`,
|
||||
type: MaskType.Motion,
|
||||
coordinates: Point.parseCoordinates(mask),
|
||||
}))
|
||||
motions.push({
|
||||
id: `add_new_motion_${motions.length}`,
|
||||
type: MaskType.Motion,
|
||||
coordinates: []
|
||||
})
|
||||
|
||||
const zones: MaskItem[] = Object.entries(cameraConfig.zones).map(([name, params], index) => ({
|
||||
id: `${name}_${index}`,
|
||||
type: MaskType.Zone,
|
||||
coordinates: Point.parseCoordinates(params.coordinates),
|
||||
}))
|
||||
zones.push({
|
||||
id: `add_new_zone_${zones.length}`,
|
||||
type: MaskType.Zone,
|
||||
coordinates: []
|
||||
})
|
||||
|
||||
const objects: MaskItem[] = Object.entries(cameraConfig.objects.filters)
|
||||
.filter(([name, params]) => params.mask !== null)
|
||||
.map(([name, params], index) => ({
|
||||
id: `${name}_${index}`,
|
||||
type: MaskType.Object,
|
||||
coordinates: Point.parseCoordinates(params.mask!),
|
||||
}))
|
||||
objects.push({
|
||||
id: `add_new_object_${objects.length}`,
|
||||
type: MaskType.Object,
|
||||
coordinates: []
|
||||
})
|
||||
|
||||
const motionItems: OneSelectItem[] = createMaskSelectItems(motions, 'Motion')
|
||||
const zonesItems: OneSelectItem[] = createMaskSelectItems(zones, 'Zone')
|
||||
const objectsItems: OneSelectItem[] = createMaskSelectItems(objects, 'Object')
|
||||
|
||||
return {
|
||||
motions,
|
||||
zones,
|
||||
objects,
|
||||
items: [...motionItems, ...zonesItems, ...objectsItems],
|
||||
}
|
||||
}, [cameraConfig])
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
const mask = [...motions, ...zones, ...objects].find(item => item.id === id)
|
||||
onSelect(mask)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder='Select Mask'
|
||||
data={items}
|
||||
onChange={handleSelect}
|
||||
{...styleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaskSelect
|
||||
@ -4,10 +4,11 @@ import CloseWithTooltip from '../buttons/CloseWithTooltip';
|
||||
|
||||
|
||||
export interface OneSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
value: string
|
||||
label: string
|
||||
group?: string
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface OneSelectFilterProps extends SelectProps {
|
||||
|
||||
@ -55,15 +55,7 @@ const AutoUpdatedImage = ({
|
||||
}
|
||||
}, [imageBlob])
|
||||
|
||||
if (isPending) return <CogwheelLoader />
|
||||
|
||||
if (isError) return (
|
||||
<Flex direction="column" justify="center" h="100%">
|
||||
<RetryError onRetry={refetch}/>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
if (!imageSrc) return null
|
||||
if (isPending || !imageSrc) return <CogwheelLoader />
|
||||
|
||||
return (
|
||||
<Flex direction="column" justify="center" h="100%">
|
||||
|
||||
35
src/shared/utils/maskPoint.ts
Normal file
35
src/shared/utils/maskPoint.ts
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
export class Point {
|
||||
constructor(public x: number, public y: number, public id: string) { }
|
||||
|
||||
toString(): string {
|
||||
return `(x: ${this.x}, y: ${this.y})`;
|
||||
}
|
||||
|
||||
|
||||
static arrayToRequest(points: Point[]): string {
|
||||
return points.map(point => `${point.x},${point.y}`).join(',')
|
||||
}
|
||||
|
||||
static parseCoordinates = (coordString: string): Point[] => {
|
||||
const numbers = coordString.split(',').map(Number);
|
||||
const points: Point[] = [];
|
||||
for (let i = 0; i < numbers.length; i += 2) {
|
||||
points.push(new Point(numbers[i], numbers[i + 1], `point_${i + 1}`))
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
static arrayToString(points: Point[]): string {
|
||||
return points.map(point => point.toString()).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
export const extractMaskNumber = (str: string): number | null => {
|
||||
const match = str.match(/\d+$/);
|
||||
if (match) {
|
||||
return parseInt(match[0], 10);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
4
src/types/mask.ts
Normal file
4
src/types/mask.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface PutMask {
|
||||
message: string
|
||||
success: boolean
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.a
|
||||
import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema';
|
||||
import AutoUpdatedImage from '../shared/components/images/AutoUpdatedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminRole } from '../hooks/useAdminRole';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
mainCard: {
|
||||
@ -48,6 +49,8 @@ const CameraCard = ({
|
||||
const navigate = useNavigate()
|
||||
const hostName = mapHostToHostname(camera.frigateHost)
|
||||
const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras
|
||||
const { isAdmin } = useAdminRole()
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (entry?.isIntersecting)
|
||||
@ -62,6 +65,14 @@ const CameraCard = ({
|
||||
const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost?.id}&${recordingsPageQuery.cameraId}=${camera.id}`
|
||||
navigate(url)
|
||||
}
|
||||
|
||||
const handleOpenEditCamera = () => {
|
||||
if (camera.frigateHost) {
|
||||
const url = routesPath.EDIT_PATH.replace(':id', camera.id)
|
||||
navigate(url)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid.Col md={6} lg={3} p='0.2rem'>
|
||||
<Card ref={ref} h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}>
|
||||
@ -73,6 +84,7 @@ const CameraCard = ({
|
||||
className={classes.bottomGroup}>
|
||||
<Flex justify='space-evenly' mt='0.5rem' w='100%'>
|
||||
<Button size='sm' onClick={handleOpenRecordings}>{t('recordings')}</Button>
|
||||
{!isAdmin ? null : <Button size='sm' onClick={handleOpenEditCamera}>{t('edit')}</Button>}
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
170
src/widgets/CameraMaskDrawer.tsx
Normal file
170
src/widgets/CameraMaskDrawer.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { Button, Center, Flex, Text } from '@mantine/core'
|
||||
import { useViewportSize } from '@mantine/hooks'
|
||||
import { IconMinus, IconPlus } from '@tabler/icons-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Konva from 'konva'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Circle, Image, Layer, Line, Stage } from 'react-konva'
|
||||
import { proxyApi } from '../services/frigate.proxy/frigate.api'
|
||||
import RetryError from '../shared/components/RetryError'
|
||||
import CogwheelLoader from '../shared/components/loaders/CogwheelLoader'
|
||||
import { Point } from '../shared/utils/maskPoint'
|
||||
|
||||
interface CameraMaskDrawerProps {
|
||||
cameraWidth: number
|
||||
cameraHeight: number
|
||||
imageUrl: string
|
||||
inPoints: Point[]
|
||||
onChange: (points: Point[]) => void
|
||||
}
|
||||
|
||||
const CameraMaskDrawer: React.FC<CameraMaskDrawerProps> = ({
|
||||
cameraWidth,
|
||||
cameraHeight,
|
||||
imageUrl,
|
||||
inPoints,
|
||||
onChange
|
||||
}) => {
|
||||
|
||||
const { height, width } = useViewportSize()
|
||||
const scaleStep = 0.1
|
||||
const [stageScale, setStageScale] = useState<number>(1)
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
|
||||
|
||||
const { data: imageBlob, refetch, isPending, isError } = useQuery({
|
||||
queryKey: [imageUrl],
|
||||
queryFn: () => proxyApi.getImageFrigate(imageUrl),
|
||||
});
|
||||
|
||||
const [points, setPoints] = useState<Point[]>(inPoints)
|
||||
|
||||
useEffect(() => {
|
||||
setPoints(inPoints)
|
||||
}, [inPoints])
|
||||
|
||||
useEffect(() => {
|
||||
onChange(points)
|
||||
}, [points])
|
||||
|
||||
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (e.target instanceof Konva.Circle) {
|
||||
return
|
||||
}
|
||||
const stage = e.target.getStage();
|
||||
const point = stage?.getPointerPosition();
|
||||
if (point) {
|
||||
const x = point.x / stageScale
|
||||
const y = point.y / stageScale
|
||||
const normX = x > cameraWidth ? cameraWidth : x < 0 ? 0 : x
|
||||
const normY = y > cameraHeight ? cameraHeight : y < 0 ? 0 : y
|
||||
const newPoint = {
|
||||
x: Math.round(normX),
|
||||
y: Math.round(normY),
|
||||
id: `point_${points.length}`,
|
||||
}
|
||||
setPoints([...points, newPoint])
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointClick = (id: string) => {
|
||||
setPoints(points.filter(point => point.id !== id))
|
||||
}
|
||||
|
||||
const setFitScale = () => {
|
||||
const scale = parseFloat(((width * 0.90) / cameraWidth).toFixed(2))
|
||||
setStageScale(scale)
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>, id: string) => {
|
||||
const updatedPoints = points.map(point => {
|
||||
if (point.id === id) {
|
||||
const x = e.target.x()
|
||||
const y = e.target.y()
|
||||
const normX = x > cameraWidth ? cameraWidth : x < 0 ? 0 : x
|
||||
const normY = y > cameraHeight ? cameraHeight : y < 0 ? 0 : y
|
||||
return {
|
||||
...point,
|
||||
x: Math.round(normX),
|
||||
y: Math.round(normY),
|
||||
};
|
||||
}
|
||||
return point;
|
||||
});
|
||||
setPoints(updatedPoints);
|
||||
};
|
||||
|
||||
const handlePlusScale = () => setStageScale(stageScale + scaleStep)
|
||||
const handleMinusScale = () => setStageScale(stageScale - scaleStep)
|
||||
const handleResetScale = () => setStageScale(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (imageBlob && imageBlob instanceof Blob) {
|
||||
const objectURL = URL.createObjectURL(imageBlob);
|
||||
const img = new window.Image();
|
||||
img.src = objectURL;
|
||||
img.onload = () => {
|
||||
setImageSrc(img);
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [imageBlob])
|
||||
|
||||
if (isPending || !imageSrc) return <CogwheelLoader />
|
||||
if (isError) return <RetryError onRetry={refetch} />
|
||||
|
||||
if (!imageUrl) return <Center>Image url does not exist</Center>
|
||||
|
||||
return (
|
||||
<Flex direction='column' w='100%' h='100%'>
|
||||
<Flex justify='center' align='center' mb='1rem'>
|
||||
<Text mr='1rem'>{(stageScale * 100).toFixed(0)}%</Text>
|
||||
<Button size='xs' variant='outline' mr='0.5rem' onClick={handlePlusScale}><IconPlus size='1.2rem' /></Button>
|
||||
<Button size='xs' variant='outline' mr='0.5rem' onClick={handleMinusScale}><IconMinus size='1.2rem' /></Button>
|
||||
<Button size='xs' variant='outline' mr='0.5rem' onClick={handleResetScale}>100%</Button>
|
||||
<Button size='xs' variant='outline' mr='0.5rem' onClick={setFitScale}>Fit</Button>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<Stage
|
||||
width={cameraWidth * stageScale}
|
||||
height={cameraHeight * stageScale}
|
||||
scaleX={stageScale}
|
||||
scaleY={stageScale}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<Layer>
|
||||
<Image
|
||||
image={imageSrc}
|
||||
width={cameraWidth}
|
||||
height={cameraHeight}
|
||||
/>
|
||||
<Line
|
||||
points={points.flatMap(point => [point.x, point.y])}
|
||||
fill="rgba(0, 0, 255, 0.5)"
|
||||
closed={points.length > 2}
|
||||
stroke="black"
|
||||
/>
|
||||
{points.map((point, index) => (
|
||||
<Circle
|
||||
key={point.id}
|
||||
x={point.x}
|
||||
y={point.y}
|
||||
radius={8 / stageScale}
|
||||
fill="red"
|
||||
draggable
|
||||
onDragEnd={(e) => handleDragEnd(e, point.id)}
|
||||
onClick={() => handlePointClick(point.id)}
|
||||
onTap={() => handlePointClick(point.id)}
|
||||
/>
|
||||
))}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default CameraMaskDrawer
|
||||
62
src/widgets/header/CameraPageHeader.tsx
Normal file
62
src/widgets/header/CameraPageHeader.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { Flex, Button, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { recordingsPageQuery } from '../../pages/RecordingsPage';
|
||||
import { routesPath } from '../../router/routes.path';
|
||||
import { GetCameraWHostWConfig } from '../../services/frigate.proxy/frigate.schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAdminRole } from '../../hooks/useAdminRole';
|
||||
import { hostConfigPageQuery } from '../../pages/HostConfigPage';
|
||||
|
||||
interface CameraPageHeaderProps {
|
||||
camera: GetCameraWHostWConfig
|
||||
editButton?: boolean,
|
||||
configButton?: boolean,
|
||||
}
|
||||
|
||||
const CameraPageHeader: React.FC<CameraPageHeaderProps> = ({
|
||||
camera,
|
||||
editButton,
|
||||
configButton,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { isAdmin } = useAdminRole()
|
||||
|
||||
const handleOpenRecordings = () => {
|
||||
if (camera.frigateHost) {
|
||||
const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost.id}&${recordingsPageQuery.cameraId}=${camera.id}`
|
||||
navigate(url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEditCamera = () => {
|
||||
if (camera.frigateHost) {
|
||||
const url = routesPath.EDIT_PATH.replace(':id', camera.id)
|
||||
navigate(url)
|
||||
}
|
||||
}
|
||||
|
||||
const hanleOpenConfig = () => {
|
||||
if (!camera.frigateHost) return
|
||||
const url = routesPath.HOST_CONFIG_PATH.replace(':id', camera.frigateHost.id) + '?' + hostConfigPageQuery.searchWord + '=' + camera.name
|
||||
navigate(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex w='100%' justify='center' align='baseline' mb='0.5rem'>
|
||||
<Text mr='1rem'>{t('camera')}: {camera.name} {camera.frigateHost ? `/ ${camera.frigateHost.name}` : ''}</Text>
|
||||
{!camera.frigateHost ? null :
|
||||
<Button mr='0.5rem' onClick={handleOpenRecordings}>{t('recordings')}</Button>
|
||||
}
|
||||
{!isAdmin || !editButton ? null :
|
||||
<Button mr='0.5rem' onClick={handleOpenEditCamera} >{t('edit')}</Button>
|
||||
}
|
||||
{!isAdmin || !configButton ? null :
|
||||
<Button mr='0.5rem' onClick={hanleOpenConfig} >{t('config')}</Button>
|
||||
}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraPageHeader;
|
||||
37
yarn.lock
37
yarn.lock
@ -2551,6 +2551,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-reconciler@^0.28.0", "@types/react-reconciler@^0.28.2":
|
||||
version "0.28.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-reconciler/-/react-reconciler-0.28.8.tgz#e51710572bcccf214306833c2438575d310b3e98"
|
||||
integrity sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^18.0.0":
|
||||
version "18.2.61"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.61.tgz#5607308495037436779939ec0348a5816c08799d"
|
||||
@ -6278,6 +6285,13 @@ iterator.prototype@^1.1.2:
|
||||
reflect.getprototypeof "^1.0.4"
|
||||
set-function-name "^2.0.1"
|
||||
|
||||
its-fine@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/its-fine/-/its-fine-1.1.2.tgz#cadb71437868e049e3bab83a3bdfec4c3189a822"
|
||||
integrity sha512-2KLlHDx31ZYloReZ8/zfV1mmHAPKtTFYNIdOkZ4H5jIL2+HjU8XIfWPqhTyWtnCYuVrO+uT/v977ISgnBxP1fw==
|
||||
dependencies:
|
||||
"@types/react-reconciler" "^0.28.0"
|
||||
|
||||
jackspeak@^2.3.5:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8"
|
||||
@ -7024,6 +7038,11 @@ klona@^2.0.4, klona@^2.0.5:
|
||||
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
|
||||
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
|
||||
|
||||
konva@^9.3.6:
|
||||
version "9.3.6"
|
||||
resolved "https://registry.yarnpkg.com/konva/-/konva-9.3.6.tgz#62b36292dbe06c56eb161d5ead221c2b5c5a8926"
|
||||
integrity sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==
|
||||
|
||||
language-subtag-registry@^0.3.20:
|
||||
version "0.3.22"
|
||||
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d"
|
||||
@ -8731,6 +8750,24 @@ react-is@^18.0.0:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||
|
||||
react-konva@^18.2.10:
|
||||
version "18.2.10"
|
||||
resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-18.2.10.tgz#5b5edc5e9ed452755d21babc353747828868decc"
|
||||
integrity sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==
|
||||
dependencies:
|
||||
"@types/react-reconciler" "^0.28.2"
|
||||
its-fine "^1.1.1"
|
||||
react-reconciler "~0.29.0"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-reconciler@~0.29.0:
|
||||
version "0.29.0"
|
||||
resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.29.0.tgz#ee769bd362915076753f3845822f2d1046603de7"
|
||||
integrity sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-refresh@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user