test(web): add eslint and PR lint validation

This commit is contained in:
Paul Armstrong 2021-02-09 11:35:33 -08:00 committed by Blake Blackshear
parent 513a099c24
commit daa759cc55
33 changed files with 5190 additions and 505 deletions

32
.github/workflows/pull_request.yml vendored Normal file
View 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
View File

@ -0,0 +1,2 @@
build/*
node_modules/*

125
web/.eslintrc.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
View File

@ -0,0 +1,5 @@
module.exports = {
printWidth: 120,
singleQuote: true,
useTabs: false,
};

View File

@ -1,5 +1,3 @@
'use strict';
module.exports = { module.exports = {
mount: { mount: {
public: { url: '/', static: true }, public: { url: '/', static: true },

View File

@ -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">

View File

@ -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>
); );

View File

@ -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);
} }

View File

@ -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);

View File

@ -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>

View File

@ -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`}>

View File

@ -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}

View File

@ -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

View File

@ -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>
); );
} }

View File

@ -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 }) {

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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>
); );

View File

@ -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}

View File

@ -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];
} }

View File

@ -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>

View File

@ -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}
/> />
); );
} }

View File

@ -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} />

View File

@ -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>

View File

@ -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>

View File

@ -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 (

View File

@ -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" />

View File

@ -1,5 +1,3 @@
'use strict';
module.exports = { module.exports = {
purge: ['./public/**/*.html', './src/**/*.jsx'], purge: ['./public/**/*.html', './src/**/*.jsx'],
darkMode: 'class', darkMode: 'class',