mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
test(web): add eslint and PR lint validation
This commit is contained in:
parent
513a099c24
commit
daa759cc55
32
.github/workflows/pull_request.yml
vendored
Normal file
32
.github/workflows/pull_request.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
name: On pull request
|
||||||
|
|
||||||
|
on: pull_request
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
web_lint:
|
||||||
|
name: Web - Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- uses: actions/setup-node@master
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- run: npm install
|
||||||
|
working-directory: ./web
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint:cmd
|
||||||
|
working-directory: ./web
|
||||||
|
|
||||||
|
web_build:
|
||||||
|
name: Web - Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- uses: actions/setup-node@master
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- run: npm install
|
||||||
|
working-directory: ./web
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./web
|
2
web/.eslintignore
Normal file
2
web/.eslintignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
build/*
|
||||||
|
node_modules/*
|
125
web/.eslintrc.js
Normal file
125
web/.eslintrc.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@babel/eslint-parser',
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: {
|
||||||
|
experimentalObjectRestSpread: true,
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
extends: ['prettier', 'preact', 'plugin:import/react'],
|
||||||
|
plugins: ['import'],
|
||||||
|
|
||||||
|
env: {
|
||||||
|
es6: true,
|
||||||
|
node: true,
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
'constructor-super': 'error',
|
||||||
|
'default-case': ['error', { commentPattern: '^no default$' }],
|
||||||
|
'handle-callback-err': ['error', '^(err|error)$'],
|
||||||
|
'new-cap': ['error', { newIsCap: true, capIsNew: false }],
|
||||||
|
'no-alert': 'error',
|
||||||
|
'no-array-constructor': 'error',
|
||||||
|
'no-caller': 'error',
|
||||||
|
'no-case-declarations': 'error',
|
||||||
|
'no-class-assign': 'error',
|
||||||
|
'no-cond-assign': 'error',
|
||||||
|
'no-console': 'error',
|
||||||
|
'no-const-assign': 'error',
|
||||||
|
'no-control-regex': 'error',
|
||||||
|
'no-debugger': 'error',
|
||||||
|
'no-delete-var': 'error',
|
||||||
|
'no-dupe-args': 'error',
|
||||||
|
'no-dupe-class-members': 'error',
|
||||||
|
'no-dupe-keys': 'error',
|
||||||
|
'no-duplicate-case': 'error',
|
||||||
|
'no-duplicate-imports': 'error',
|
||||||
|
'no-empty-character-class': 'error',
|
||||||
|
'no-empty-pattern': 'error',
|
||||||
|
'no-eval': 'error',
|
||||||
|
'no-ex-assign': 'error',
|
||||||
|
'no-extend-native': 'error',
|
||||||
|
'no-extra-bind': 'error',
|
||||||
|
'no-extra-boolean-cast': 'error',
|
||||||
|
'no-fallthrough': 'error',
|
||||||
|
'no-floating-decimal': 'error',
|
||||||
|
'no-func-assign': 'error',
|
||||||
|
'no-implied-eval': 'error',
|
||||||
|
'no-inner-declarations': ['error', 'functions'],
|
||||||
|
'no-invalid-regexp': 'error',
|
||||||
|
'no-irregular-whitespace': 'error',
|
||||||
|
'no-iterator': 'error',
|
||||||
|
'no-label-var': 'error',
|
||||||
|
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
|
||||||
|
'no-lone-blocks': 'error',
|
||||||
|
'no-loop-func': 'error',
|
||||||
|
'no-multi-str': 'error',
|
||||||
|
'no-native-reassign': 'error',
|
||||||
|
'no-negated-in-lhs': 'error',
|
||||||
|
'no-new': 'error',
|
||||||
|
'no-new-func': 'error',
|
||||||
|
'no-new-object': 'error',
|
||||||
|
'no-new-require': 'error',
|
||||||
|
'no-new-symbol': 'error',
|
||||||
|
'no-new-wrappers': 'error',
|
||||||
|
'no-obj-calls': 'error',
|
||||||
|
'no-octal': 'error',
|
||||||
|
'no-octal-escape': 'error',
|
||||||
|
'no-path-concat': 'error',
|
||||||
|
'no-proto': 'error',
|
||||||
|
'no-redeclare': 'error',
|
||||||
|
'no-regex-spaces': 'error',
|
||||||
|
'no-return-assign': ['error', 'except-parens'],
|
||||||
|
'no-script-url': 'error',
|
||||||
|
'no-self-assign': 'error',
|
||||||
|
'no-self-compare': 'error',
|
||||||
|
'no-sequences': 'error',
|
||||||
|
'no-shadow-restricted-names': 'error',
|
||||||
|
'no-sparse-arrays': 'error',
|
||||||
|
'no-this-before-super': 'error',
|
||||||
|
'no-throw-literal': 'error',
|
||||||
|
'no-trailing-spaces': 'error',
|
||||||
|
'no-undef': 'error',
|
||||||
|
'no-undef-init': 'error',
|
||||||
|
'no-unexpected-multiline': 'error',
|
||||||
|
'no-unmodified-loop-condition': 'error',
|
||||||
|
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
|
||||||
|
'no-unreachable': 'error',
|
||||||
|
'no-unsafe-finally': 'error',
|
||||||
|
'no-unused-vars': ['error', { vars: 'all', args: 'none', ignoreRestSiblings: true }],
|
||||||
|
'no-useless-call': 'error',
|
||||||
|
'no-useless-computed-key': 'error',
|
||||||
|
'no-useless-concat': 'error',
|
||||||
|
'no-useless-constructor': 'error',
|
||||||
|
'no-useless-escape': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'no-with': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'prefer-rest-params': 'error',
|
||||||
|
'use-isnan': 'error',
|
||||||
|
'valid-typeof': 'error',
|
||||||
|
camelcase: 'off',
|
||||||
|
eqeqeq: ['error', 'allow-null'],
|
||||||
|
indent: ['error', 2],
|
||||||
|
quotes: ['error', 'single', 'avoid-escape'],
|
||||||
|
radix: 'error',
|
||||||
|
yoda: ['error', 'never'],
|
||||||
|
|
||||||
|
'import/no-unresolved': 'error',
|
||||||
|
|
||||||
|
'react-hooks/exhaustive-deps': 'error',
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
node: {
|
||||||
|
extensions: ['.js', '.jsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
4
web/babel.config.js
Normal file
4
web/babel.config.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ['@babel/preset-env'],
|
||||||
|
plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'preact.h' }]],
|
||||||
|
};
|
4894
web/package-lock.json
generated
4894
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,20 +4,32 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||||
"prebuild": "rimraf build",
|
"prebuild": "rimraf build",
|
||||||
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build"
|
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
|
||||||
|
"lint": "npm run lint:cmd -- --fix",
|
||||||
|
"lint:cmd": "eslint ./ --ext .jsx,.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"idb-keyval": "^5.0.2",
|
||||||
|
"immer": "^8.0.1",
|
||||||
|
"preact": "^10.5.9",
|
||||||
|
"preact-async-route": "^2.2.1",
|
||||||
|
"preact-router": "^3.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.12.13",
|
||||||
|
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||||
|
"@babel/preset-env": "^7.12.13",
|
||||||
"@prefresh/snowpack": "^3.0.1",
|
"@prefresh/snowpack": "^3.0.1",
|
||||||
"@snowpack/plugin-postcss": "^1.1.0",
|
"@snowpack/plugin-postcss": "^1.1.0",
|
||||||
"autoprefixer": "^10.2.1",
|
"autoprefixer": "^10.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"idb-keyval": "^5.0.2",
|
"eslint": "^7.19.0",
|
||||||
"immer": "^8.0.1",
|
"eslint-config-preact": "^1.1.3",
|
||||||
|
"eslint-config-prettier": "^7.2.0",
|
||||||
|
"eslint-plugin-import": "^2.22.1",
|
||||||
"postcss": "^8.2.2",
|
"postcss": "^8.2.2",
|
||||||
"postcss-cli": "^8.3.1",
|
"postcss-cli": "^8.3.1",
|
||||||
"preact": "^10.5.9",
|
"prettier": "^2.2.1",
|
||||||
"preact-async-route": "^2.2.1",
|
|
||||||
"preact-router": "^3.2.1",
|
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"snowpack": "^3.0.11",
|
"snowpack": "^3.0.11",
|
||||||
"snowpack-plugin-hash": "^0.14.2",
|
"snowpack-plugin-hash": "^0.14.2",
|
||||||
|
@ -1,8 +1,3 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [
|
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||||
require('tailwindcss'),
|
|
||||||
require('autoprefixer'),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
5
web/prettier.config.js
Normal file
5
web/prettier.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
printWidth: 120,
|
||||||
|
singleQuote: true,
|
||||||
|
useTabs: false,
|
||||||
|
};
|
@ -1,5 +1,3 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mount: {
|
mount: {
|
||||||
public: { url: '/', static: true },
|
public: { url: '/', static: true },
|
||||||
|
@ -6,15 +6,15 @@ import AppBar from './components/AppBar';
|
|||||||
import Cameras from './routes/Cameras';
|
import Cameras from './routes/Cameras';
|
||||||
import { Router } from 'preact-router';
|
import { Router } from 'preact-router';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import Api, { FetchStatus, useConfig } from './api';
|
|
||||||
import { DarkModeProvider, DrawerProvider } from './context';
|
import { DarkModeProvider, DrawerProvider } from './context';
|
||||||
|
import { FetchStatus, useConfig } from './api';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { data, status } = useConfig();
|
const { status } = useConfig();
|
||||||
return (
|
return (
|
||||||
<DarkModeProvider>
|
<DarkModeProvider>
|
||||||
<DrawerProvider>
|
<DrawerProvider>
|
||||||
<div class="w-full">
|
<div className="w-full">
|
||||||
<AppBar title="Frigate" />
|
<AppBar title="Frigate" />
|
||||||
{status !== FetchStatus.LOADED ? (
|
{status !== FetchStatus.LOADED ? (
|
||||||
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
||||||
|
@ -3,8 +3,8 @@ import LinkedLogo from './components/LinkedLogo';
|
|||||||
import { Match } from 'preact-router/match';
|
import { Match } from 'preact-router/match';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useConfig } from './api';
|
import { useConfig } from './api';
|
||||||
|
import { useMemo } from 'preact/hooks';
|
||||||
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
||||||
import { useCallback, useMemo } from 'preact/hooks';
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
@ -42,9 +42,9 @@ export default function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = memo(function Header() {
|
const Header = memo(() => {
|
||||||
return (
|
return (
|
||||||
<div class="text-gray-500">
|
<div className="text-gray-500">
|
||||||
<LinkedLogo />
|
<LinkedLogo />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { h, createContext } from 'preact';
|
import { h, createContext } from 'preact';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
|
import { useContext, useEffect, useReducer } from 'preact/hooks';
|
||||||
|
|
||||||
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');
|
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');
|
||||||
|
|
||||||
@ -20,23 +20,23 @@ export default Api;
|
|||||||
|
|
||||||
function reducer(state, { type, payload, meta }) {
|
function reducer(state, { type, payload, meta }) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'REQUEST': {
|
case 'REQUEST': {
|
||||||
const { url, request } = payload;
|
const { url, fetchId } = payload;
|
||||||
const data = state.queries[url]?.data || null;
|
const data = state.queries[url]?.data || null;
|
||||||
return produce(state, (draftState) => {
|
return produce(state, (draftState) => {
|
||||||
draftState.queries[url] = { status: FetchStatus.LOADING, data };
|
draftState.queries[url] = { status: FetchStatus.LOADING, data, fetchId };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RESPONSE': {
|
case 'RESPONSE': {
|
||||||
const { url, ok, data } = payload;
|
const { url, ok, data, fetchId } = payload;
|
||||||
return produce(state, (draftState) => {
|
return produce(state, (draftState) => {
|
||||||
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data };
|
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data, fetchId };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,8 +45,8 @@ export const ApiProvider = ({ children }) => {
|
|||||||
return <Api.Provider value={{ state, dispatch }}>{children}</Api.Provider>;
|
return <Api.Provider value={{ state, dispatch }}>{children}</Api.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function shouldFetch(state, url, forceRefetch = false) {
|
function shouldFetch(state, url, fetchId = null) {
|
||||||
if (forceRefetch || !(url in state.queries)) {
|
if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const { status } = state.queries[url];
|
const { status } = state.queries[url];
|
||||||
@ -54,23 +54,23 @@ function shouldFetch(state, url, forceRefetch = false) {
|
|||||||
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
|
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFetch(url, forceRefetch) {
|
export function useFetch(url, fetchId) {
|
||||||
const { state, dispatch } = useContext(Api);
|
const { state, dispatch } = useContext(Api);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldFetch(state, url, forceRefetch)) {
|
if (!shouldFetch(state, url, fetchId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchData() {
|
||||||
await dispatch({ type: 'REQUEST', payload: { url } });
|
await dispatch({ type: 'REQUEST', payload: { url, fetchId } });
|
||||||
const response = await fetch(`${state.host}${url}`);
|
const response = await fetch(`${state.host}${url}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data } });
|
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data, fetchId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchConfig();
|
fetchData();
|
||||||
}, [url, forceRefetch]);
|
}, [url, fetchId, state, dispatch]);
|
||||||
|
|
||||||
if (!(url in state.queries)) {
|
if (!(url in state.queries)) {
|
||||||
return { data: null, status: FetchStatus.NONE };
|
return { data: null, status: FetchStatus.NONE };
|
||||||
@ -83,26 +83,26 @@ export function useFetch(url, forceRefetch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useApiHost() {
|
export function useApiHost() {
|
||||||
const { state, dispatch } = useContext(Api);
|
const { state } = useContext(Api);
|
||||||
return state.host;
|
return state.host;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEvents(searchParams, forceRefetch) {
|
export function useEvents(searchParams, fetchId) {
|
||||||
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
|
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
return useFetch(url, forceRefetch);
|
return useFetch(url, fetchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEvent(eventId, forceRefetch) {
|
export function useEvent(eventId, fetchId) {
|
||||||
const url = `/api/events/${eventId}`;
|
const url = `/api/events/${eventId}`;
|
||||||
return useFetch(url, forceRefetch);
|
return useFetch(url, fetchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConfig(searchParams, forceRefetch) {
|
export function useConfig(searchParams, fetchId) {
|
||||||
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
return useFetch(url, forceRefetch);
|
return useFetch(url, fetchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStats(searchParams, forceRefetch) {
|
export function useStats(searchParams, fetchId) {
|
||||||
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
|
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||||
return useFetch(url, forceRefetch);
|
return useFetch(url, fetchId);
|
||||||
}
|
}
|
||||||
|
@ -12,16 +12,14 @@ import { useLayoutEffect, useCallback, useRef, useState } from 'preact/hooks';
|
|||||||
|
|
||||||
// We would typically preserve these in component state
|
// We would typically preserve these in component state
|
||||||
// But need to avoid too many re-renders
|
// But need to avoid too many re-renders
|
||||||
let ticking = false;
|
|
||||||
let lastScrollY = window.scrollY;
|
let lastScrollY = window.scrollY;
|
||||||
|
|
||||||
export default function AppBar({ title }) {
|
export default function AppBar({ title }) {
|
||||||
const [show, setShow] = useState(true);
|
const [show, setShow] = useState(true);
|
||||||
const [atZero, setAtZero] = useState(window.scrollY === 0);
|
const [atZero, setAtZero] = useState(window.scrollY === 0);
|
||||||
const [_, setDrawerVisible] = useState(true);
|
|
||||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||||
const { currentMode, persistedMode, setDarkMode } = useDarkMode();
|
const { setDarkMode } = useDarkMode();
|
||||||
const { showDrawer, setShowDrawer } = useDrawer();
|
const { setShowDrawer } = useDrawer();
|
||||||
|
|
||||||
const handleSelectDarkMode = useCallback(
|
const handleSelectDarkMode = useCallback(
|
||||||
(value, label) => {
|
(value, label) => {
|
||||||
@ -37,15 +35,11 @@ export default function AppBar({ title }) {
|
|||||||
(event) => {
|
(event) => {
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
// if (!ticking) {
|
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
setShow(scrollY <= 0 || lastScrollY > scrollY);
|
setShow(scrollY <= 0 || lastScrollY > scrollY);
|
||||||
setAtZero(scrollY === 0);
|
setAtZero(scrollY === 0);
|
||||||
ticking = false;
|
|
||||||
lastScrollY = scrollY;
|
lastScrollY = scrollY;
|
||||||
});
|
});
|
||||||
ticking = true;
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
[setShow]
|
[setShow]
|
||||||
);
|
);
|
||||||
@ -55,7 +49,7 @@ export default function AppBar({ title }) {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('scroll', scrollListener);
|
document.removeEventListener('scroll', scrollListener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [scrollListener]);
|
||||||
|
|
||||||
const handleShowMenu = useCallback(() => {
|
const handleShowMenu = useCallback(() => {
|
||||||
setShowMoreMenu(true);
|
setShowMoreMenu(true);
|
||||||
|
@ -17,7 +17,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams, showFps
|
|||||||
},
|
},
|
||||||
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
|
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
}, [key, searchParams, setFps]);
|
}, [key, setFps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import ActivityIndicator from './ActivityIndicator';
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
import { useApiHost, useConfig } from '../api';
|
import { useApiHost, useConfig } from '../api';
|
||||||
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function CameraImage({ camera, onload, searchParams = '' }) {
|
export default function CameraImage({ camera, onload, searchParams = '' }) {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
@ -22,14 +22,11 @@ export default function CameraImage({ camera, onload, searchParams = '' }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [setAvailableWidth, width]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
}, [resizeObserver, containerRef.current]);
|
}, [resizeObserver, containerRef]);
|
||||||
|
|
||||||
const scaledHeight = useMemo(() => Math.min(Math.ceil(availableWidth / aspectRatio), height), [
|
const scaledHeight = useMemo(() => Math.min(Math.ceil(availableWidth / aspectRatio), height), [
|
||||||
availableWidth,
|
availableWidth,
|
||||||
@ -38,26 +35,28 @@ export default function CameraImage({ camera, onload, searchParams = '' }) {
|
|||||||
]);
|
]);
|
||||||
const scaledWidth = useMemo(() => Math.ceil(scaledHeight * aspectRatio), [scaledHeight, aspectRatio]);
|
const scaledWidth = useMemo(() => Math.ceil(scaledHeight * aspectRatio), [scaledHeight, aspectRatio]);
|
||||||
|
|
||||||
const img = useMemo(() => new Image(), [camera]);
|
const img = useMemo(() => new Image(), []);
|
||||||
img.onload = useCallback(
|
img.onload = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
const ctx = canvasRef.current.getContext('2d');
|
if (canvasRef.current) {
|
||||||
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
const ctx = canvasRef.current.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
}
|
||||||
onload && onload(event);
|
onload && onload(event);
|
||||||
},
|
},
|
||||||
[setHasLoaded, onload, canvasRef.current]
|
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scaledHeight || !canvasRef.current) {
|
if (scaledHeight === 0 || !canvasRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
|
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
|
||||||
}, [apiHost, name, img, searchParams, scaledHeight]);
|
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative w-full" ref={containerRef}>
|
||||||
<canvas height={scaledHeight} ref={canvasRef} width={scaledWidth} />
|
<canvas height={scaledHeight} ref={canvasRef} width={scaledWidth} />
|
||||||
{!hasLoaded ? (
|
{!hasLoaded ? (
|
||||||
<div className="absolute inset-0 flex justify-center" style={`height: ${scaledHeight}px`}>
|
<div className="absolute inset-0 flex justify-center" style={`height: ${scaledHeight}px`}>
|
||||||
|
@ -26,14 +26,14 @@ export default function Box({
|
|||||||
{media || header ? (
|
{media || header ? (
|
||||||
<Element href={href} {...props}>
|
<Element href={href} {...props}>
|
||||||
{media}
|
{media}
|
||||||
<div class="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
||||||
</Element>
|
</Element>
|
||||||
) : null}
|
) : null}
|
||||||
{buttons.length || content ? (
|
{buttons.length || content ? (
|
||||||
<div class="pl-4 pb-2">
|
<div className="pl-4 pb-2">
|
||||||
{content || null}
|
{content || null}
|
||||||
{buttons.length ? (
|
{buttons.length ? (
|
||||||
<div class="flex space-x-4 -ml-2">
|
<div className="flex space-x-4 -ml-2">
|
||||||
{buttons.map(({ name, href }) => (
|
{buttons.map(({ name, href }) => (
|
||||||
<Button key={name} href={href} type="text">
|
<Button key={name} href={href} type="text">
|
||||||
{name}
|
{name}
|
||||||
|
@ -6,7 +6,7 @@ export default function LinkedLogo() {
|
|||||||
return (
|
return (
|
||||||
<Heading size="lg">
|
<Heading size="lg">
|
||||||
<a className="transition-colors flex items-center space-x-4 dark:text-white hover:text-blue-500" href="/">
|
<a className="transition-colors flex items-center space-x-4 dark:text-white hover:text-blue-500" href="/">
|
||||||
<div class="w-10">
|
<div className="w-10">
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
Frigate
|
Frigate
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import RelativeModal from './RelativeModal';
|
import RelativeModal from './RelativeModal';
|
||||||
import { useCallback, useEffect } from 'preact/hooks';
|
import { useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
|
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
|
||||||
return relativeTo ? (
|
return relativeTo ? (
|
||||||
@ -21,21 +21,12 @@ export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
|
|||||||
onSelect && onSelect(value, label);
|
onSelect && onSelect(value, label);
|
||||||
}, [onSelect, value, label]);
|
}, [onSelect, value, label]);
|
||||||
|
|
||||||
const handleKeydown = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
onSelect && onSelect(value, label);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onSelect, value, label]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
|
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' : ''
|
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
|
||||||
}`}
|
}`}
|
||||||
onclick={handleClick}
|
onClick={handleClick}
|
||||||
role="option"
|
role="option"
|
||||||
>
|
>
|
||||||
{Icon ? (
|
{Icon ? (
|
||||||
@ -43,7 +34,7 @@ export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
|
|||||||
<Icon />
|
<Icon />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div class="whitespace-nowrap">{label}</div>
|
<div className="whitespace-nowrap">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { Link } from 'preact-router/match';
|
import { Link } from 'preact-router/match';
|
||||||
import { useCallback, useState } from 'preact/hooks';
|
import { useCallback } from 'preact/hooks';
|
||||||
import { useDrawer } from '../context';
|
import { useDrawer } from '../context';
|
||||||
|
|
||||||
export default function NavigationDrawer({ children, header }) {
|
export default function NavigationDrawer({ children, header }) {
|
||||||
|
@ -44,7 +44,7 @@ export default function RelativeModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ref.current]
|
[ref]
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -84,7 +84,7 @@ export default function RelativeModal({
|
|||||||
const focusable = ref.current.querySelector('[tabindex]');
|
const focusable = ref.current.querySelector('[tabindex]');
|
||||||
focusable && focusable.focus();
|
focusable && focusable.focus();
|
||||||
}
|
}
|
||||||
}, [relativeTo && relativeTo.current, ref && ref.current, widthRelative]);
|
}, [relativeTo, ref, widthRelative]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (position.top >= 0) {
|
if (position.top >= 0) {
|
||||||
@ -92,7 +92,7 @@ export default function RelativeModal({
|
|||||||
} else {
|
} else {
|
||||||
setShow(false);
|
setShow(false);
|
||||||
}
|
}
|
||||||
}, [show, position.top, ref.current]);
|
}, [show, position, ref]);
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@ -102,7 +102,7 @@ export default function RelativeModal({
|
|||||||
className={`z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-all duration-75 transform scale-90 opacity-0 overflow-scroll ${
|
className={`z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-all duration-75 transform scale-90 opacity-0 overflow-scroll ${
|
||||||
show ? 'scale-100 opacity-100' : ''
|
show ? 'scale-100 opacity-100' : ''
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
onkeydown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
role={role}
|
role={role}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={position.top >= 0 ? position : null}
|
style={position.top >= 0 ? position : null}
|
||||||
|
@ -28,7 +28,7 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
|||||||
onChange && onChange(value, label);
|
onChange && onChange(value, label);
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange, options]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
@ -38,32 +38,34 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
|||||||
const handleKeydown = useCallback(
|
const handleKeydown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
if (!showMenu) {
|
if (!showMenu) {
|
||||||
setShowMenu(true);
|
setShowMenu(true);
|
||||||
setFocused(selected);
|
setFocused(selected);
|
||||||
} else {
|
} else {
|
||||||
setSelected(focused);
|
setSelected(focused);
|
||||||
onChange && onChange(options[focused].value, options[focused].label);
|
onChange && onChange(options[focused].value, options[focused].label);
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': {
|
||||||
const newIndex = focused + 1;
|
const newIndex = focused + 1;
|
||||||
newIndex < options.length && setFocused(newIndex);
|
newIndex < options.length && setFocused(newIndex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'ArrowUp': {
|
case 'ArrowUp': {
|
||||||
const newIndex = focused - 1;
|
const newIndex = focused - 1;
|
||||||
newIndex > -1 && setFocused(newIndex);
|
newIndex > -1 && setFocused(newIndex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// no default
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setShowMenu, setFocused, focused, selected]
|
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
@ -80,7 +82,8 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
|||||||
setSelected(selectedIndex);
|
setSelected(selectedIndex);
|
||||||
setFocused(selectedIndex);
|
setFocused(selectedIndex);
|
||||||
}
|
}
|
||||||
}, [propSelected]);
|
// DO NOT include `selected`
|
||||||
|
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
@ -2,9 +2,7 @@ import { h } from 'preact';
|
|||||||
import { useCallback, useState } from 'preact/hooks';
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Switch({ checked, id, onChange }) {
|
export default function Switch({ checked, id, onChange }) {
|
||||||
const [internalState, setInternalState] = useState(checked);
|
|
||||||
const [isFocused, setFocused] = useState(false);
|
const [isFocused, setFocused] = useState(false);
|
||||||
const [isHovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@ -25,12 +23,12 @@ export default function Switch({ checked, id, onChange }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
for={id}
|
htmlFor={id}
|
||||||
className={`flex items-center justify-center ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
className={`flex items-center justify-center ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onmouseover={handleFocus}
|
onMouseOver={handleFocus}
|
||||||
onmouseout={handleBlur}
|
onMouseOut={handleBlur}
|
||||||
className={`w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
|
className={`w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
@ -38,7 +36,7 @@ export default function Switch({ checked, id, onChange }) {
|
|||||||
className="absolute left-48"
|
className="absolute left-48"
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
tabindex="0"
|
tabIndex="0"
|
||||||
id={id}
|
id={id}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
@ -30,7 +30,7 @@ export function Tr({ children, className = '' }) {
|
|||||||
|
|
||||||
export function Th({ children, className = '', colspan }) {
|
export function Th({ children, className = '', colspan }) {
|
||||||
return (
|
return (
|
||||||
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colspan={colspan}>
|
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan}>
|
||||||
{children}
|
{children}
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
@ -38,7 +38,7 @@ export function Th({ children, className = '', colspan }) {
|
|||||||
|
|
||||||
export function Td({ children, className = '', colspan }) {
|
export function Td({ children, className = '', colspan }) {
|
||||||
return (
|
return (
|
||||||
<td className={`p-2 px-1 lg:p-4 ${className}`} colspan={colspan}>
|
<td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan}>
|
||||||
{children}
|
{children}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
@ -43,12 +43,12 @@ export default function TextField({
|
|||||||
[onChangeText, setValue]
|
[onChangeText, setValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset the state if the prop value changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propValue !== value) {
|
if (propValue !== value) {
|
||||||
setValue(propValue);
|
setValue(propValue);
|
||||||
}
|
}
|
||||||
}, [propValue, setValue]);
|
// DO NOT include `value`
|
||||||
|
}, [propValue, setValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const labelMoved = isFocused || value !== '';
|
const labelMoved = isFocused || value !== '';
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ export default function TextField({
|
|||||||
>
|
>
|
||||||
<label className="flex space-x-2 items-center">
|
<label className="flex space-x-2 items-center">
|
||||||
{LeadingIcon ? (
|
{LeadingIcon ? (
|
||||||
<div class="w-10 h-full">
|
<div className="w-10 h-full">
|
||||||
<LeadingIcon />
|
<LeadingIcon />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -72,8 +72,8 @@ export default function TextField({
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onInput={handleChange}
|
onInput={handleChange}
|
||||||
readonly={readonly}
|
readOnly={readonly}
|
||||||
tabindex="0"
|
tabIndex="0"
|
||||||
type={keyboardType}
|
type={keyboardType}
|
||||||
value={value}
|
value={value}
|
||||||
{...props}
|
{...props}
|
||||||
@ -87,7 +87,7 @@ export default function TextField({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{TrailingIcon ? (
|
{TrailingIcon ? (
|
||||||
<div class="w-10 h-10">
|
<div className="w-10 h-10">
|
||||||
<TrailingIcon />
|
<TrailingIcon />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { h, createContext } from 'preact';
|
import { h, createContext } from 'preact';
|
||||||
import { get as getData, set as setData } from 'idb-keyval';
|
import { get as getData, set as setData } from 'idb-keyval';
|
||||||
import produce from 'immer';
|
|
||||||
import { useCallback, useContext, useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
import { useCallback, useContext, useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
const DarkMode = createContext(null);
|
const DarkMode = createContext(null);
|
||||||
@ -27,11 +26,7 @@ export function DarkModeProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, [setDarkMode]);
|
||||||
|
|
||||||
if (persistedMode === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMediaMatch = useCallback(
|
const handleMediaMatch = useCallback(
|
||||||
({ matches }) => {
|
({ matches }) => {
|
||||||
@ -52,7 +47,7 @@ export function DarkModeProvider({ children }) {
|
|||||||
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
query.addEventListener('change', handleMediaMatch);
|
query.addEventListener('change', handleMediaMatch);
|
||||||
handleMediaMatch(query);
|
handleMediaMatch(query);
|
||||||
}, [persistedMode]);
|
}, [persistedMode, handleMediaMatch]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (currentMode === 'dark') {
|
if (currentMode === 'dark') {
|
||||||
@ -62,7 +57,9 @@ export function DarkModeProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
return <DarkMode.Provider value={{ currentMode, persistedMode, setDarkMode }}>{children}</DarkMode.Provider>;
|
return !persistedMode ? null : (
|
||||||
|
<DarkMode.Provider value={{ currentMode, persistedMode, setDarkMode }}>{children}</DarkMode.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDarkMode() {
|
export function useDarkMode() {
|
||||||
@ -110,7 +107,7 @@ export function usePersistence(key, defaultValue = undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [key]);
|
}, [key, defaultValue, setValue]);
|
||||||
|
|
||||||
return [value, setValue, loaded];
|
return [value, setValue, loaded];
|
||||||
}
|
}
|
||||||
|
@ -6,31 +6,26 @@ import Heading from '../components/Heading';
|
|||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import SettingsIcon from '../icons/Settings';
|
import SettingsIcon from '../icons/Settings';
|
||||||
import Switch from '../components/Switch';
|
import Switch from '../components/Switch';
|
||||||
import { route } from 'preact-router';
|
|
||||||
import { usePersistence } from '../context';
|
import { usePersistence } from '../context';
|
||||||
import { useCallback, useContext, useMemo, useState } from 'preact/hooks';
|
import { useCallback, useMemo, useState } from 'preact/hooks';
|
||||||
import { useApiHost, useConfig } from '../api';
|
import { useApiHost, useConfig } from '../api';
|
||||||
|
|
||||||
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
export default function Camera({ camera }) {
|
export default function Camera({ camera }) {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
if (!config) {
|
const cameraConfig = config?.cameras[camera];
|
||||||
return <div>{`No camera named ${camera}`}</div>;
|
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
||||||
}
|
|
||||||
|
|
||||||
const cameraConfig = config.cameras[camera];
|
|
||||||
const [options, setOptions, optionsLoaded] = usePersistence(`${camera}-feed`, Object.freeze({}));
|
|
||||||
|
|
||||||
const objectCount = useMemo(() => cameraConfig.objects.track.length, [cameraConfig]);
|
|
||||||
|
|
||||||
const handleSetOption = useCallback(
|
const handleSetOption = useCallback(
|
||||||
(id, value) => {
|
(id, value) => {
|
||||||
const newOptions = { ...options, [id]: value };
|
const newOptions = { ...options, [id]: value };
|
||||||
setOptions(newOptions);
|
setOptions(newOptions);
|
||||||
},
|
},
|
||||||
[options]
|
[options, setOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchParams = useMemo(
|
const searchParams = useMemo(
|
||||||
@ -41,7 +36,7 @@ export default function Camera({ camera }) {
|
|||||||
return memo;
|
return memo;
|
||||||
}, [])
|
}, [])
|
||||||
),
|
),
|
||||||
[camera, options]
|
[options]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleSettings = useCallback(() => {
|
const handleToggleSettings = useCallback(() => {
|
||||||
@ -52,27 +47,27 @@ export default function Camera({ camera }) {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Switch checked={options['bbox']} id="bbox" onChange={handleSetOption} />
|
<Switch checked={options['bbox']} id="bbox" onChange={handleSetOption} />
|
||||||
<span class="inline-flex">Bounding box</span>
|
<span className="inline-flex">Bounding box</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Switch checked={options['timestamp']} id="timestamp" onChange={handleSetOption} />
|
<Switch checked={options['timestamp']} id="timestamp" onChange={handleSetOption} />
|
||||||
<span class="inline-flex">Timestamp</span>
|
<span className="inline-flex">Timestamp</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Switch checked={options['zones']} id="zones" onChange={handleSetOption} />
|
<Switch checked={options['zones']} id="zones" onChange={handleSetOption} />
|
||||||
<span class="inline-flex">Zones</span>
|
<span className="inline-flex">Zones</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Switch checked={options['mask']} id="mask" onChange={handleSetOption} />
|
<Switch checked={options['mask']} id="mask" onChange={handleSetOption} />
|
||||||
<span class="inline-flex">Masks</span>
|
<span className="inline-flex">Masks</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Switch checked={options['motion']} id="motion" onChange={handleSetOption} />
|
<Switch checked={options['motion']} id="motion" onChange={handleSetOption} />
|
||||||
<span class="inline-flex">Motion boxes</span>
|
<span className="inline-flex">Motion boxes</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Switch checked={options['regions']} id="regions" onChange={handleSetOption} />
|
<Switch checked={options['regions']} id="regions" onChange={handleSetOption} />
|
||||||
<span class="inline-flex">Regions</span>
|
<span className="inline-flex">Regions</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -81,14 +76,12 @@ export default function Camera({ camera }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Heading size="2xl">{camera}</Heading>
|
<Heading size="2xl">{camera}</Heading>
|
||||||
{optionsLoaded ? (
|
<div>
|
||||||
<div>
|
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
|
||||||
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button onClick={handleToggleSettings} type="text">
|
<Button onClick={handleToggleSettings} type="text">
|
||||||
<span class="w-5 h-5">
|
<span className="w-5 h-5">
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span>{showSettings ? 'Hide' : 'Show'} Options</span>
|
<span>{showSettings ? 'Hide' : 'Show'} Options</span>
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import Card from '../components/Card';
|
import Card from '../components/Card.jsx';
|
||||||
import Button from '../components/Button';
|
import Button from '../components/Button.jsx';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading.jsx';
|
||||||
import Switch from '../components/Switch';
|
import Switch from '../components/Switch.jsx';
|
||||||
import { route } from 'preact-router';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
||||||
import { useApiHost, useConfig } from '../api';
|
import { useApiHost, useConfig } from '../api';
|
||||||
|
|
||||||
export default function CameraMasks({ camera, url }) {
|
export default function CameraMasks({ camera, url }) {
|
||||||
@ -14,10 +13,6 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
const [imageScale, setImageScale] = useState(1);
|
const [imageScale, setImageScale] = useState(1);
|
||||||
const [snap, setSnap] = useState(true);
|
const [snap, setSnap] = useState(true);
|
||||||
|
|
||||||
if (!(camera in config.cameras)) {
|
|
||||||
return <div>{`No camera named ${camera}`}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cameraConfig = config.cameras[camera];
|
const cameraConfig = config.cameras[camera];
|
||||||
const {
|
const {
|
||||||
width,
|
width,
|
||||||
@ -38,7 +33,7 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
[camera, width, setImageScale]
|
[width, setImageScale]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -46,14 +41,14 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resizeObserver.observe(imageRef.current);
|
resizeObserver.observe(imageRef.current);
|
||||||
}, [resizeObserver, imageRef.current]);
|
}, [resizeObserver, imageRef]);
|
||||||
|
|
||||||
const [motionMaskPoints, setMotionMaskPoints] = useState(
|
const [motionMaskPoints, setMotionMaskPoints] = useState(
|
||||||
Array.isArray(motionMask)
|
Array.isArray(motionMask)
|
||||||
? motionMask.map((mask) => getPolylinePoints(mask))
|
? motionMask.map((mask) => getPolylinePoints(mask))
|
||||||
: motionMask
|
: motionMask
|
||||||
? [getPolylinePoints(motionMask)]
|
? [getPolylinePoints(motionMask)]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
const [zonePoints, setZonePoints] = useState(
|
const [zonePoints, setZonePoints] = useState(
|
||||||
@ -67,8 +62,8 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
[name]: Array.isArray(objectFilters[name].mask)
|
[name]: Array.isArray(objectFilters[name].mask)
|
||||||
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
|
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
|
||||||
: objectFilters[name].mask
|
: objectFilters[name].mask
|
||||||
? [getPolylinePoints(objectFilters[name].mask)]
|
? [getPolylinePoints(objectFilters[name].mask)]
|
||||||
: [],
|
: [],
|
||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
@ -94,26 +89,6 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
[editing]
|
[editing]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectEditable = useCallback(
|
|
||||||
(name) => {
|
|
||||||
setEditing(name);
|
|
||||||
},
|
|
||||||
[setEditing]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveEditable = useCallback(
|
|
||||||
(name) => {
|
|
||||||
const filteredZonePoints = Object.keys(zonePoints)
|
|
||||||
.filter((zoneName) => zoneName !== name)
|
|
||||||
.reduce((memo, name) => {
|
|
||||||
memo[name] = zonePoints[name];
|
|
||||||
return memo;
|
|
||||||
}, {});
|
|
||||||
setZonePoints(filteredZonePoints);
|
|
||||||
},
|
|
||||||
[zonePoints, setZonePoints]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Motion mask methods
|
// Motion mask methods
|
||||||
const handleAddMask = useCallback(() => {
|
const handleAddMask = useCallback(() => {
|
||||||
const newMotionMaskPoints = [...motionMaskPoints, []];
|
const newMotionMaskPoints = [...motionMaskPoints, []];
|
||||||
@ -171,11 +146,11 @@ ${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`)
|
|||||||
const handleCopyZones = useCallback(async () => {
|
const handleCopyZones = useCallback(async () => {
|
||||||
await window.navigator.clipboard.writeText(` zones:
|
await window.navigator.clipboard.writeText(` zones:
|
||||||
${Object.keys(zonePoints)
|
${Object.keys(zonePoints)
|
||||||
.map(
|
.map(
|
||||||
(zoneName) => ` ${zoneName}:
|
(zoneName) => ` ${zoneName}:
|
||||||
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
|
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
|
||||||
)
|
)
|
||||||
.join('\n')}`);
|
.join('\n')}`);
|
||||||
}, [zonePoints]);
|
}, [zonePoints]);
|
||||||
|
|
||||||
// Object methods
|
// Object methods
|
||||||
@ -207,14 +182,14 @@ ${Object.keys(zonePoints)
|
|||||||
await window.navigator.clipboard.writeText(` objects:
|
await window.navigator.clipboard.writeText(` objects:
|
||||||
filters:
|
filters:
|
||||||
${Object.keys(objectMaskPoints)
|
${Object.keys(objectMaskPoints)
|
||||||
.map((objectName) =>
|
.map((objectName) =>
|
||||||
objectMaskPoints[objectName].length
|
objectMaskPoints[objectName].length
|
||||||
? ` ${objectName}:
|
? ` ${objectName}:
|
||||||
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
|
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')}`);
|
.join('\n')}`);
|
||||||
}, [objectMaskPoints]);
|
}, [objectMaskPoints]);
|
||||||
|
|
||||||
const handleAddToObjectMask = useCallback(
|
const handleAddToObjectMask = useCallback(
|
||||||
@ -239,7 +214,7 @@ ${Object.keys(objectMaskPoints)
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex-col space-y-4">
|
<div className="flex-col space-y-4">
|
||||||
<Heading size="2xl">{camera} mask & zone creator</Heading>
|
<Heading size="2xl">{camera} mask & zone creator</Heading>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@ -265,12 +240,12 @@ ${Object.keys(objectMaskPoints)
|
|||||||
height={height}
|
height={height}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<span>Snap to edges</span> <Switch checked={snap} onChange={handleChangeSnap} />
|
<span>Snap to edges</span> <Switch checked={snap} onChange={handleChangeSnap} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-col space-y-4">
|
<div className="flex-col space-y-4">
|
||||||
<MaskValues
|
<MaskValues
|
||||||
editing={editing}
|
editing={editing}
|
||||||
title="Motion masks"
|
title="Motion masks"
|
||||||
@ -314,7 +289,7 @@ ${Object.keys(objectMaskPoints)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maskYamlKeyPrefix(points) {
|
function maskYamlKeyPrefix(points) {
|
||||||
return ` - `;
|
return ' - ';
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoneYamlKeyPrefix(points, key) {
|
function zoneYamlKeyPrefix(points, key) {
|
||||||
@ -323,43 +298,40 @@ function zoneYamlKeyPrefix(points, key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function objectYamlKeyPrefix(points, key, subkey) {
|
function objectYamlKeyPrefix(points, key, subkey) {
|
||||||
return ` - `;
|
return ' - ';
|
||||||
}
|
}
|
||||||
|
|
||||||
const MaskInset = 20;
|
const MaskInset = 20;
|
||||||
|
|
||||||
function EditableMask({ onChange, points, scale, snap, width, height }) {
|
function boundedSize(value, maxValue, snap) {
|
||||||
if (!points) {
|
const newValue = Math.min(Math.max(0, Math.round(value)), maxValue);
|
||||||
return null;
|
if (snap) {
|
||||||
}
|
if (newValue <= MaskInset) {
|
||||||
const boundingRef = useRef(null);
|
return 0;
|
||||||
|
} else if (maxValue - newValue <= MaskInset) {
|
||||||
function boundedSize(value, maxValue) {
|
return maxValue;
|
||||||
const newValue = Math.min(Math.max(0, Math.round(value)), maxValue);
|
|
||||||
if (snap) {
|
|
||||||
if (newValue <= MaskInset) {
|
|
||||||
return 0;
|
|
||||||
} else if (maxValue - newValue <= MaskInset) {
|
|
||||||
return maxValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableMask({ onChange, points, scale, snap, width, height }) {
|
||||||
|
const boundingRef = useRef(null);
|
||||||
|
|
||||||
const handleMovePoint = useCallback(
|
const handleMovePoint = useCallback(
|
||||||
(index, newX, newY) => {
|
(index, newX, newY) => {
|
||||||
if (newX < 0 && newY < 0) {
|
if (newX < 0 && newY < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let x = boundedSize(newX / scale, width, snap);
|
const x = boundedSize(newX / scale, width, snap);
|
||||||
let y = boundedSize(newY / scale, height, snap);
|
const y = boundedSize(newY / scale, height, snap);
|
||||||
|
|
||||||
const newPoints = [...points];
|
const newPoints = [...points];
|
||||||
newPoints[index] = [x, y];
|
newPoints[index] = [x, y];
|
||||||
onChange(newPoints);
|
onChange(newPoints);
|
||||||
},
|
},
|
||||||
[scale, points, snap]
|
[height, width, onChange, scale, points, snap]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add a new point between the closest two other points
|
// Add a new point between the closest two other points
|
||||||
@ -370,7 +342,6 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
|
|||||||
const scaledY = boundedSize((offsetY - MaskInset) / scale, height, snap);
|
const scaledY = boundedSize((offsetY - MaskInset) / scale, height, snap);
|
||||||
const newPoint = [scaledX, scaledY];
|
const newPoint = [scaledX, scaledY];
|
||||||
|
|
||||||
let closest;
|
|
||||||
const { index } = points.reduce(
|
const { index } = points.reduce(
|
||||||
(result, point, i) => {
|
(result, point, i) => {
|
||||||
const nextPoint = points.length === i + 1 ? points[0] : points[i + 1];
|
const nextPoint = points.length === i + 1 ? points[0] : points[i + 1];
|
||||||
@ -385,7 +356,7 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
|
|||||||
newPoints.splice(index, 0, newPoint);
|
newPoints.splice(index, 0, newPoint);
|
||||||
onChange(newPoints);
|
onChange(newPoints);
|
||||||
},
|
},
|
||||||
[scale, points, onChange, snap]
|
[height, width, scale, points, onChange, snap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemovePoint = useCallback(
|
const handleRemovePoint = useCallback(
|
||||||
@ -407,16 +378,16 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
|
|||||||
{!scaledPoints
|
{!scaledPoints
|
||||||
? null
|
? null
|
||||||
: scaledPoints.map(([x, y], i) => (
|
: scaledPoints.map(([x, y], i) => (
|
||||||
<PolyPoint
|
<PolyPoint
|
||||||
boundingRef={boundingRef}
|
boundingRef={boundingRef}
|
||||||
index={i}
|
index={i}
|
||||||
onMove={handleMovePoint}
|
onMove={handleMovePoint}
|
||||||
onRemove={handleRemovePoint}
|
onRemove={handleRemovePoint}
|
||||||
x={x + MaskInset}
|
x={x + MaskInset}
|
||||||
y={y + MaskInset}
|
y={y + MaskInset}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="absolute inset-0 right-0 bottom-0" onclick={handleAddPoint} ref={boundingRef} />
|
<div className="absolute inset-0 right-0 bottom-0" onClick={handleAddPoint} ref={boundingRef} />
|
||||||
<svg
|
<svg
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
@ -488,15 +459,15 @@ function MaskValues({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden" onmouseover={handleMousein} onmouseout={handleMouseout}>
|
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
|
||||||
<div class="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<Heading className="flex-grow self-center" size="base">
|
<Heading className="flex-grow self-center" size="base">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Button onClick={onCopy}>Copy</Button>
|
<Button onClick={onCopy}>Copy</Button>
|
||||||
<Button onClick={onCreate}>Add</Button>
|
<Button onClick={onCreate}>Add</Button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2">
|
<pre className="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2">
|
||||||
{yamlPrefix}
|
{yamlPrefix}
|
||||||
{Object.keys(points).map((mainkey) => {
|
{Object.keys(points).map((mainkey) => {
|
||||||
if (isMulti) {
|
if (isMulti) {
|
||||||
@ -522,20 +493,19 @@ function MaskValues({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Item
|
|
||||||
mainkey={mainkey}
|
|
||||||
editing={editing}
|
|
||||||
handleAdd={onAdd ? handleAdd : undefined}
|
|
||||||
handleEdit={handleEdit}
|
|
||||||
handleRemove={handleRemove}
|
|
||||||
points={points[mainkey]}
|
|
||||||
showButtons={showButtons}
|
|
||||||
yamlKeyPrefix={yamlKeyPrefix}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
mainkey={mainkey}
|
||||||
|
editing={editing}
|
||||||
|
handleAdd={onAdd ? handleAdd : undefined}
|
||||||
|
handleEdit={handleEdit}
|
||||||
|
handleRemove={handleRemove}
|
||||||
|
points={points[mainkey]}
|
||||||
|
showButtons={showButtons}
|
||||||
|
yamlKeyPrefix={yamlKeyPrefix}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@ -613,18 +583,18 @@ function PolyPoint({ boundingRef, index, x, y, onMove, onRemove }) {
|
|||||||
}
|
}
|
||||||
onMove(index, event.layerX - PolyPointRadius * 2, event.layerY - PolyPointRadius * 2);
|
onMove(index, event.layerX - PolyPointRadius * 2, event.layerY - PolyPointRadius * 2);
|
||||||
},
|
},
|
||||||
[onMove, index, boundingRef.current]
|
[onMove, index, boundingRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragStart = useCallback(() => {
|
const handleDragStart = useCallback(() => {
|
||||||
boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
|
boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
|
||||||
setHidden(true);
|
setHidden(true);
|
||||||
}, [setHidden, boundingRef.current, handleDragOver]);
|
}, [setHidden, boundingRef, handleDragOver]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
|
boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
|
||||||
setHidden(false);
|
setHidden(false);
|
||||||
}, [setHidden, boundingRef.current, handleDragOver]);
|
}, [setHidden, boundingRef, handleDragOver]);
|
||||||
|
|
||||||
const handleRightClick = useCallback(
|
const handleRightClick = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@ -644,10 +614,10 @@ function PolyPoint({ boundingRef, index, x, y, onMove, onRemove }) {
|
|||||||
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
|
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
|
||||||
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
|
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
|
||||||
draggable
|
draggable
|
||||||
onclick={handleClick}
|
onClick={handleClick}
|
||||||
oncontextmenu={handleRightClick}
|
onContextMenu={handleRightClick}
|
||||||
ondragstart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
ondragend={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,19 +2,15 @@ import { h } from 'preact';
|
|||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
import Card from '../components/Card';
|
import Card from '../components/Card';
|
||||||
import CameraImage from '../components/CameraImage';
|
import CameraImage from '../components/CameraImage';
|
||||||
import Heading from '../components/Heading';
|
import { useConfig, FetchStatus } from '../api';
|
||||||
import { route } from 'preact-router';
|
|
||||||
import { useConfig } from '../api';
|
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Cameras() {
|
export default function Cameras() {
|
||||||
const { data: config, status } = useConfig();
|
const { data: config, status } = useConfig();
|
||||||
|
|
||||||
if (!config) {
|
return status !== FetchStatus.LOADED ? (
|
||||||
return <p>loading…</p>;
|
<ActivityIndicator />
|
||||||
}
|
) : (
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
||||||
{Object.keys(config.cameras).map((camera) => (
|
{Object.keys(config.cameras).map((camera) => (
|
||||||
<Camera name={camera} />
|
<Camera name={camera} />
|
||||||
|
@ -3,53 +3,56 @@ import ActivityIndicator from '../components/ActivityIndicator';
|
|||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import { FetchStatus, useConfig, useStats } from '../api';
|
import { useConfig, useStats } from '../api';
|
||||||
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
|
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
export default function Debug() {
|
export default function Debug() {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
const [timeoutId, setTimeoutId] = useState(null);
|
const [timeoutId, setTimeoutId] = useState(null);
|
||||||
|
const { data: stats } = useStats(null, timeoutId);
|
||||||
|
|
||||||
const forceUpdate = useCallback(async () => {
|
const forceUpdate = useCallback(() => {
|
||||||
setTimeoutId(setTimeout(forceUpdate, 1000));
|
const timeoutId = setTimeout(forceUpdate, 1000);
|
||||||
|
setTimeoutId(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}, []);
|
}, [forceUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [timeoutId]);
|
}, [timeoutId]);
|
||||||
const { data: stats, status } = useStats(null, timeoutId);
|
|
||||||
|
|
||||||
if (stats === null && (status === FetchStatus.LOADING || status === FetchStatus.NONE)) {
|
const { detectors, service, detection_fps, ...cameras } = stats || emptyObject;
|
||||||
return <ActivityIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { detectors, detection_fps, service, ...cameras } = stats;
|
const detectorNames = Object.keys(detectors || emptyObject);
|
||||||
|
const detectorDataKeys = Object.keys(detectors ? detectors[detectorNames[0]] : emptyObject);
|
||||||
|
const cameraNames = Object.keys(cameras || emptyObject);
|
||||||
|
const cameraDataKeys = Object.keys(cameras[cameraNames[0]] || emptyObject);
|
||||||
|
|
||||||
const detectorNames = Object.keys(detectors);
|
const handleCopyConfig = useCallback(() => {
|
||||||
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
|
async function copy() {
|
||||||
|
await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
||||||
const cameraNames = Object.keys(cameras);
|
}
|
||||||
const cameraDataKeys = Object.keys(cameras[cameraNames[0]]);
|
copy();
|
||||||
|
|
||||||
const handleCopyConfig = useCallback(async () => {
|
|
||||||
await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
return (
|
return stats === null ? (
|
||||||
<div class="space-y-4">
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
<Heading>
|
<Heading>
|
||||||
Debug <span className="text-sm">{service.version}</span>
|
Debug <span className="text-sm">{service.version}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div class="min-w-0 overflow-auto">
|
<div className="min-w-0 overflow-auto">
|
||||||
<Table className="w-full">
|
<Table className="w-full">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
@ -72,7 +75,7 @@ export default function Debug() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 overflow-auto">
|
<div className="min-w-0 overflow-auto">
|
||||||
<Table className="w-full">
|
<Table className="w-full">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
|
@ -3,7 +3,7 @@ import ActivityIndicator from '../components/ActivityIndicator';
|
|||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import { FetchStatus, useApiHost, useEvent } from '../api';
|
import { FetchStatus, useApiHost, useEvent } from '../api';
|
||||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
||||||
|
|
||||||
export default function Event({ eventId }) {
|
export default function Event({ eventId }) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
@ -54,7 +54,7 @@ export default function Event({ eventId }) {
|
|||||||
{data.has_clip ? (
|
{data.has_clip ? (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Heading size="sm">Clip</Heading>
|
<Heading size="sm">Clip</Heading>
|
||||||
<video autoplay className="w-100" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
|
<video autoPlay className="w-100" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
) : (
|
) : (
|
||||||
<p>No clip available</p>
|
<p>No clip available</p>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
import Card from '../components/Card';
|
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import Select from '../components/Select';
|
import Select from '../components/Select';
|
||||||
@ -8,39 +7,39 @@ import produce from 'immer';
|
|||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
|
||||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
|
||||||
const reducer = (state = initialState, action) => {
|
const reducer = (state = initialState, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'APPEND_EVENTS': {
|
case 'APPEND_EVENTS': {
|
||||||
const {
|
const {
|
||||||
meta: { searchString },
|
meta: { searchString },
|
||||||
payload,
|
payload,
|
||||||
} = action;
|
} = action;
|
||||||
return produce(state, (draftState) => {
|
return produce(state, (draftState) => {
|
||||||
draftState.searchStrings[searchString] = true;
|
draftState.searchStrings[searchString] = true;
|
||||||
draftState.events.push(...payload);
|
draftState.events.push(...payload);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'REACHED_END': {
|
case 'REACHED_END': {
|
||||||
const {
|
const {
|
||||||
meta: { searchString },
|
meta: { searchString },
|
||||||
} = action;
|
} = action;
|
||||||
return produce(state, (draftState) => {
|
return produce(state, (draftState) => {
|
||||||
draftState.reachedEnd = true;
|
draftState.reachedEnd = true;
|
||||||
draftState.searchStrings[searchString] = true;
|
draftState.searchStrings[searchString] = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RESET':
|
case 'RESET':
|
||||||
return initialState;
|
return initialState;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,7 +64,7 @@ export default function Events({ path: pathname } = {}) {
|
|||||||
if (Array.isArray(data) && data.length < API_LIMIT) {
|
if (Array.isArray(data) && data.length < API_LIMIT) {
|
||||||
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data, searchString, searchStrings]);
|
||||||
|
|
||||||
const observer = useRef(
|
const observer = useRef(
|
||||||
new IntersectionObserver((entries, observer) => {
|
new IntersectionObserver((entries, observer) => {
|
||||||
@ -96,7 +95,7 @@ export default function Events({ path: pathname } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[observer.current, reachedEnd]
|
[observer, reachedEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFilter = useCallback(
|
const handleFilter = useCallback(
|
||||||
@ -121,7 +120,7 @@ export default function Events({ path: pathname } = {}) {
|
|||||||
<Table className="min-w-full table-fixed">
|
<Table className="min-w-full table-fixed">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th></Th>
|
<Th />
|
||||||
<Th>Camera</Th>
|
<Th>Camera</Th>
|
||||||
<Th>Label</Th>
|
<Th>Label</Th>
|
||||||
<Th>Score</Th>
|
<Th>Score</Th>
|
||||||
@ -213,7 +212,7 @@ function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
|||||||
params.set(paramName, name);
|
params.set(paramName, name);
|
||||||
removeDefaultSearchKeys(params);
|
removeDefaultSearchKeys(params);
|
||||||
return `${pathname}?${params.toString()}`;
|
return `${pathname}?${params.toString()}`;
|
||||||
}, [searchParams]);
|
}, [searchParams, paramName, pathname, name]);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@ -223,7 +222,7 @@ function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
|||||||
params.set(paramName, name);
|
params.set(paramName, name);
|
||||||
onFilter(params);
|
onFilter(params);
|
||||||
},
|
},
|
||||||
[href, searchParams]
|
[href, searchParams, onFilter, paramName, name]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||||
import ArrowDropup from '../icons/ArrowDropup';
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
import Card from '../components/Card';
|
|
||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Select from '../components/Select';
|
import Select from '../components/Select';
|
||||||
@ -22,13 +21,13 @@ export default function StyleGuide() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Heading size="md">Button</Heading>
|
<Heading size="md">Button</Heading>
|
||||||
<div class="flex space-x-4 mb-4">
|
<div className="flex space-x-4 mb-4">
|
||||||
<Button>Default</Button>
|
<Button>Default</Button>
|
||||||
<Button color="red">Danger</Button>
|
<Button color="red">Danger</Button>
|
||||||
<Button color="green">Save</Button>
|
<Button color="green">Save</Button>
|
||||||
<Button disabled>Disabled</Button>
|
<Button disabled>Disabled</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-4 mb-4">
|
<div className="flex space-x-4 mb-4">
|
||||||
<Button type="text">Default</Button>
|
<Button type="text">Default</Button>
|
||||||
<Button color="red" type="text">
|
<Button color="red" type="text">
|
||||||
Danger
|
Danger
|
||||||
@ -40,7 +39,7 @@ export default function StyleGuide() {
|
|||||||
Disabled
|
Disabled
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-4 mb-4">
|
<div className="flex space-x-4 mb-4">
|
||||||
<Button type="outlined">Default</Button>
|
<Button type="outlined">Default</Button>
|
||||||
<Button color="red" type="outlined">
|
<Button color="red" type="outlined">
|
||||||
Danger
|
Danger
|
||||||
@ -54,7 +53,7 @@ export default function StyleGuide() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading size="md">Switch</Heading>
|
<Heading size="md">Switch</Heading>
|
||||||
<div class="flex">
|
<div className="flex">
|
||||||
<div>
|
<div>
|
||||||
<p>Disabled, off</p>
|
<p>Disabled, off</p>
|
||||||
<Switch />
|
<Switch />
|
||||||
@ -74,12 +73,12 @@ export default function StyleGuide() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading size="md">Select</Heading>
|
<Heading size="md">Select</Heading>
|
||||||
<div class="flex space-x-4 mb-4 max-w-4xl">
|
<div className="flex space-x-4 mb-4 max-w-4xl">
|
||||||
<Select label="Basic select box" options={['All', 'None', 'Other']} selected="None" />
|
<Select label="Basic select box" options={['All', 'None', 'Other']} selected="None" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Heading size="md">TextField</Heading>
|
<Heading size="md">TextField</Heading>
|
||||||
<div class="flex-col space-y-4 max-w-4xl">
|
<div className="flex-col space-y-4 max-w-4xl">
|
||||||
<TextField label="Default text field" />
|
<TextField label="Default text field" />
|
||||||
<TextField label="Pre-filled" value="This is my pre-filled value" />
|
<TextField label="Pre-filled" value="This is my pre-filled value" />
|
||||||
<TextField label="With help" helpText="This is some help text" />
|
<TextField label="With help" helpText="This is some help text" />
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
purge: ['./public/**/*.html', './src/**/*.jsx'],
|
purge: ['./public/**/*.html', './src/**/*.jsx'],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
|
Loading…
Reference in New Issue
Block a user