فهرست منبع

test(web): add eslint and PR lint validation

Paul Armstrong 4 سال پیش
والد
کامیت
daa759cc55

+ 32 - 0
.github/workflows/pull_request.yml

@@ -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 - 0
web/.eslintignore

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

+ 125 - 0
web/.eslintrc.js

@@ -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 - 0
web/babel.config.js

@@ -0,0 +1,4 @@
+module.exports = {
+  presets: ['@babel/preset-env'],
+  plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'preact.h' }]],
+};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2768 - 106
web/package-lock.json


+ 18 - 6
web/package.json

@@ -4,20 +4,32 @@
   "scripts": {
     "start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
     "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": {
+    "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",
     "@snowpack/plugin-postcss": "^1.1.0",
     "autoprefixer": "^10.2.1",
     "cross-env": "^7.0.3",
-    "idb-keyval": "^5.0.2",
-    "immer": "^8.0.1",
+    "eslint": "^7.19.0",
+    "eslint-config-preact": "^1.1.3",
+    "eslint-config-prettier": "^7.2.0",
+    "eslint-plugin-import": "^2.22.1",
     "postcss": "^8.2.2",
     "postcss-cli": "^8.3.1",
-    "preact": "^10.5.9",
-    "preact-async-route": "^2.2.1",
-    "preact-router": "^3.2.1",
+    "prettier": "^2.2.1",
     "rimraf": "^3.0.2",
     "snowpack": "^3.0.11",
     "snowpack-plugin-hash": "^0.14.2",

+ 1 - 6
web/postcss.config.js

@@ -1,8 +1,3 @@
-'use strict';
-
 module.exports = {
-  plugins: [
-    require('tailwindcss'),
-    require('autoprefixer'),
-  ],
+  plugins: [require('tailwindcss'), require('autoprefixer')],
 };

+ 5 - 0
web/prettier.config.js

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

+ 0 - 2
web/snowpack.config.js

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

+ 3 - 3
web/src/App.jsx

@@ -6,15 +6,15 @@ import AppBar from './components/AppBar';
 import Cameras from './routes/Cameras';
 import { Router } from 'preact-router';
 import Sidebar from './Sidebar';
-import Api, { FetchStatus, useConfig } from './api';
 import { DarkModeProvider, DrawerProvider } from './context';
+import { FetchStatus, useConfig } from './api';
 
 export default function App() {
-  const { data, status } = useConfig();
+  const { status } = useConfig();
   return (
     <DarkModeProvider>
       <DrawerProvider>
-        <div class="w-full">
+        <div className="w-full">
           <AppBar title="Frigate" />
           {status !== FetchStatus.LOADED ? (
             <div className="flex flex-grow-1 min-h-screen justify-center items-center">

+ 3 - 3
web/src/Sidebar.jsx

@@ -3,8 +3,8 @@ import LinkedLogo from './components/LinkedLogo';
 import { Match } from 'preact-router/match';
 import { memo } from 'preact/compat';
 import { useConfig } from './api';
+import { useMemo } from 'preact/hooks';
 import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
-import { useCallback, useMemo } from 'preact/hooks';
 
 export default function Sidebar() {
   const { data: config } = useConfig();
@@ -42,9 +42,9 @@ export default function Sidebar() {
   );
 }
 
-const Header = memo(function Header() {
+const Header = memo(() => {
   return (
-    <div class="text-gray-500">
+    <div className="text-gray-500">
       <LinkedLogo />
     </div>
   );

+ 34 - 34
web/src/api/index.jsx

@@ -1,6 +1,6 @@
 import { h, createContext } from 'preact';
 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 || '');
 
@@ -20,23 +20,23 @@ export default Api;
 
 function reducer(state, { type, payload, meta }) {
   switch (type) {
-    case 'REQUEST': {
-      const { url, request } = payload;
-      const data = state.queries[url]?.data || null;
-      return produce(state, (draftState) => {
-        draftState.queries[url] = { status: FetchStatus.LOADING, data };
-      });
-    }
+  case 'REQUEST': {
+    const { url, fetchId } = payload;
+    const data = state.queries[url]?.data || null;
+    return produce(state, (draftState) => {
+      draftState.queries[url] = { status: FetchStatus.LOADING, data, fetchId };
+    });
+  }
 
-    case 'RESPONSE': {
-      const { url, ok, data } = payload;
-      return produce(state, (draftState) => {
-        draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data };
-      });
-    }
+  case 'RESPONSE': {
+    const { url, ok, data, fetchId } = payload;
+    return produce(state, (draftState) => {
+      draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data, fetchId };
+    });
+  }
 
-    default:
-      return state;
+  default:
+    return state;
   }
 }
 
@@ -45,8 +45,8 @@ export const ApiProvider = ({ children }) => {
   return <Api.Provider value={{ state, dispatch }}>{children}</Api.Provider>;
 };
 
-function shouldFetch(state, url, forceRefetch = false) {
-  if (forceRefetch || !(url in state.queries)) {
+function shouldFetch(state, url, fetchId = null) {
+  if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
     return true;
   }
   const { status } = state.queries[url];
@@ -54,23 +54,23 @@ function shouldFetch(state, url, forceRefetch = false) {
   return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
 }
 
-export function useFetch(url, forceRefetch) {
+export function useFetch(url, fetchId) {
   const { state, dispatch } = useContext(Api);
 
   useEffect(() => {
-    if (!shouldFetch(state, url, forceRefetch)) {
+    if (!shouldFetch(state, url, fetchId)) {
       return;
     }
 
-    async function fetchConfig() {
-      await dispatch({ type: 'REQUEST', payload: { url } });
+    async function fetchData() {
+      await dispatch({ type: 'REQUEST', payload: { url, fetchId } });
       const response = await fetch(`${state.host}${url}`);
       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();
-  }, [url, forceRefetch]);
+    fetchData();
+  }, [url, fetchId, state, dispatch]);
 
   if (!(url in state.queries)) {
     return { data: null, status: FetchStatus.NONE };
@@ -83,26 +83,26 @@ export function useFetch(url, forceRefetch) {
 }
 
 export function useApiHost() {
-  const { state, dispatch } = useContext(Api);
+  const { state } = useContext(Api);
   return state.host;
 }
 
-export function useEvents(searchParams, forceRefetch) {
+export function useEvents(searchParams, fetchId) {
   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}`;
-  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()}` : ''}`;
-  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()}` : ''}`;
-  return useFetch(url, forceRefetch);
+  return useFetch(url, fetchId);
 }

+ 3 - 9
web/src/components/AppBar.jsx

@@ -12,16 +12,14 @@ import { useLayoutEffect, useCallback, useRef, useState } from 'preact/hooks';
 
 // We would typically preserve these in component state
 // But need to avoid too many re-renders
-let ticking = false;
 let lastScrollY = window.scrollY;
 
 export default function AppBar({ title }) {
   const [show, setShow] = useState(true);
   const [atZero, setAtZero] = useState(window.scrollY === 0);
-  const [_, setDrawerVisible] = useState(true);
   const [showMoreMenu, setShowMoreMenu] = useState(false);
-  const { currentMode, persistedMode, setDarkMode } = useDarkMode();
-  const { showDrawer, setShowDrawer } = useDrawer();
+  const { setDarkMode } = useDarkMode();
+  const { setShowDrawer } = useDrawer();
 
   const handleSelectDarkMode = useCallback(
     (value, label) => {
@@ -37,15 +35,11 @@ export default function AppBar({ title }) {
     (event) => {
       const scrollY = window.scrollY;
 
-      // if (!ticking) {
       window.requestAnimationFrame(() => {
         setShow(scrollY <= 0 || lastScrollY > scrollY);
         setAtZero(scrollY === 0);
-        ticking = false;
         lastScrollY = scrollY;
       });
-      ticking = true;
-      // }
     },
     [setShow]
   );
@@ -55,7 +49,7 @@ export default function AppBar({ title }) {
     return () => {
       document.removeEventListener('scroll', scrollListener);
     };
-  }, []);
+  }, [scrollListener]);
 
   const handleShowMenu = useCallback(() => {
     setShowMoreMenu(true);

+ 1 - 1
web/src/components/AutoUpdatingCameraImage.jsx

@@ -17,7 +17,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams, showFps
       },
       loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
     );
-  }, [key, searchParams, setFps]);
+  }, [key, setFps]);
 
   return (
     <div>

+ 12 - 13
web/src/components/CameraImage.jsx

@@ -1,7 +1,7 @@
 import { h } from 'preact';
 import ActivityIndicator from './ActivityIndicator';
 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 = '' }) {
   const { data: config } = useConfig();
@@ -22,14 +22,11 @@ export default function CameraImage({ camera, onload, searchParams = '' }) {
         }
       });
     });
-  }, [setAvailableWidth, width]);
+  }, []);
 
   useEffect(() => {
-    if (!containerRef.current) {
-      return;
-    }
     resizeObserver.observe(containerRef.current);
-  }, [resizeObserver, containerRef.current]);
+  }, [resizeObserver, containerRef]);
 
   const scaledHeight = useMemo(() => Math.min(Math.ceil(availableWidth / aspectRatio), height), [
     availableWidth,
@@ -38,26 +35,28 @@ export default function CameraImage({ camera, onload, searchParams = '' }) {
   ]);
   const scaledWidth = useMemo(() => Math.ceil(scaledHeight * aspectRatio), [scaledHeight, aspectRatio]);
 
-  const img = useMemo(() => new Image(), [camera]);
+  const img = useMemo(() => new Image(), []);
   img.onload = useCallback(
     (event) => {
       setHasLoaded(true);
-      const ctx = canvasRef.current.getContext('2d');
-      ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
+      if (canvasRef.current) {
+        const ctx = canvasRef.current.getContext('2d');
+        ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
+      }
       onload && onload(event);
     },
-    [setHasLoaded, onload, canvasRef.current]
+    [img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
   );
 
   useEffect(() => {
-    if (!scaledHeight || !canvasRef.current) {
+    if (scaledHeight === 0 || !canvasRef.current) {
       return;
     }
     img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
-  }, [apiHost, name, img, searchParams, scaledHeight]);
+  }, [apiHost, canvasRef, name, img, searchParams, scaledHeight]);
 
   return (
-    <div className="relative" ref={containerRef}>
+    <div className="relative w-full" ref={containerRef}>
       <canvas height={scaledHeight} ref={canvasRef} width={scaledWidth} />
       {!hasLoaded ? (
         <div className="absolute inset-0 flex justify-center" style={`height: ${scaledHeight}px`}>

+ 3 - 3
web/src/components/Card.jsx

@@ -26,14 +26,14 @@ export default function Box({
       {media || header ? (
         <Element href={href} {...props}>
           {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>
       ) : null}
       {buttons.length || content ? (
-        <div class="pl-4 pb-2">
+        <div className="pl-4 pb-2">
           {content || null}
           {buttons.length ? (
-            <div class="flex space-x-4 -ml-2">
+            <div className="flex space-x-4 -ml-2">
               {buttons.map(({ name, href }) => (
                 <Button key={name} href={href} type="text">
                   {name}

+ 1 - 1
web/src/components/LinkedLogo.jsx

@@ -6,7 +6,7 @@ export default function LinkedLogo() {
   return (
     <Heading size="lg">
       <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 />
         </div>
         Frigate

+ 3 - 12
web/src/components/Menu.jsx

@@ -1,6 +1,6 @@
 import { h } from 'preact';
 import RelativeModal from './RelativeModal';
-import { useCallback, useEffect } from 'preact/hooks';
+import { useCallback } from 'preact/hooks';
 
 export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
   return relativeTo ? (
@@ -21,21 +21,12 @@ export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
     onSelect && onSelect(value, label);
   }, [onSelect, value, label]);
 
-  const handleKeydown = useCallback(
-    (event) => {
-      if (event.key === 'Enter') {
-        onSelect && onSelect(value, label);
-      }
-    },
-    [onSelect, value, label]
-  );
-
   return (
     <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 ${
         focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
       }`}
-      onclick={handleClick}
+      onClick={handleClick}
       role="option"
     >
       {Icon ? (
@@ -43,7 +34,7 @@ export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
           <Icon />
         </div>
       ) : null}
-      <div class="whitespace-nowrap">{label}</div>
+      <div className="whitespace-nowrap">{label}</div>
     </div>
   );
 }

+ 1 - 1
web/src/components/NavigationDrawer.jsx

@@ -1,6 +1,6 @@
 import { h, Fragment } from 'preact';
 import { Link } from 'preact-router/match';
-import { useCallback, useState } from 'preact/hooks';
+import { useCallback } from 'preact/hooks';
 import { useDrawer } from '../context';
 
 export default function NavigationDrawer({ children, header }) {

+ 4 - 4
web/src/components/RelativeModal.jsx

@@ -44,7 +44,7 @@ export default function RelativeModal({
         return;
       }
     },
-    [ref.current]
+    [ref]
   );
 
   useLayoutEffect(() => {
@@ -84,7 +84,7 @@ export default function RelativeModal({
       const focusable = ref.current.querySelector('[tabindex]');
       focusable && focusable.focus();
     }
-  }, [relativeTo && relativeTo.current, ref && ref.current, widthRelative]);
+  }, [relativeTo, ref, widthRelative]);
 
   useEffect(() => {
     if (position.top >= 0) {
@@ -92,7 +92,7 @@ export default function RelativeModal({
     } else {
       setShow(false);
     }
-  }, [show, position.top, ref.current]);
+  }, [show, position, ref]);
 
   const menu = (
     <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 ${
           show ? 'scale-100 opacity-100' : ''
         } ${className}`}
-        onkeydown={handleKeydown}
+        onKeyDown={handleKeydown}
         role={role}
         ref={ref}
         style={position.top >= 0 ? position : null}

+ 26 - 23
web/src/components/Select.jsx

@@ -28,7 +28,7 @@ export default function Select({ label, onChange, options: inputOptions = [], se
       onChange && onChange(value, label);
       setShowMenu(false);
     },
-    [onChange]
+    [onChange, options]
   );
 
   const handleClick = useCallback(() => {
@@ -38,32 +38,34 @@ export default function Select({ label, onChange, options: inputOptions = [], se
   const handleKeydown = useCallback(
     (event) => {
       switch (event.key) {
-        case 'Enter': {
-          if (!showMenu) {
-            setShowMenu(true);
-            setFocused(selected);
-          } else {
-            setSelected(focused);
-            onChange && onChange(options[focused].value, options[focused].label);
-            setShowMenu(false);
-          }
-          break;
+      case 'Enter': {
+        if (!showMenu) {
+          setShowMenu(true);
+          setFocused(selected);
+        } else {
+          setSelected(focused);
+          onChange && onChange(options[focused].value, options[focused].label);
+          setShowMenu(false);
         }
+        break;
+      }
 
-        case 'ArrowDown': {
-          const newIndex = focused + 1;
-          newIndex < options.length && setFocused(newIndex);
-          break;
-        }
+      case 'ArrowDown': {
+        const newIndex = focused + 1;
+        newIndex < options.length && setFocused(newIndex);
+        break;
+      }
 
-        case 'ArrowUp': {
-          const newIndex = focused - 1;
-          newIndex > -1 && setFocused(newIndex);
-          break;
-        }
+      case 'ArrowUp': {
+        const newIndex = focused - 1;
+        newIndex > -1 && setFocused(newIndex);
+        break;
+      }
+
+        // no default
       }
     },
-    [setShowMenu, setFocused, focused, selected]
+    [onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
   );
 
   const handleDismiss = useCallback(() => {
@@ -80,7 +82,8 @@ export default function Select({ label, onChange, options: inputOptions = [], se
       setSelected(selectedIndex);
       setFocused(selectedIndex);
     }
-  }, [propSelected]);
+    // DO NOT include `selected`
+  }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
 
   return (
     <Fragment>

+ 4 - 6
web/src/components/Switch.jsx

@@ -2,9 +2,7 @@ import { h } from 'preact';
 import { useCallback, useState } from 'preact/hooks';
 
 export default function Switch({ checked, id, onChange }) {
-  const [internalState, setInternalState] = useState(checked);
   const [isFocused, setFocused] = useState(false);
-  const [isHovered, setHovered] = useState(false);
 
   const handleChange = useCallback(
     (event) => {
@@ -25,12 +23,12 @@ export default function Switch({ checked, id, onChange }) {
 
   return (
     <label
-      for={id}
+      htmlFor={id}
       className={`flex items-center justify-center ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
     >
       <div
-        onmouseover={handleFocus}
-        onmouseout={handleBlur}
+        onMouseOver={handleFocus}
+        onMouseOut={handleBlur}
         className={`w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
       >
         <div className="relative overflow-hidden">
@@ -38,7 +36,7 @@ export default function Switch({ checked, id, onChange }) {
             className="absolute left-48"
             onBlur={handleBlur}
             onFocus={handleFocus}
-            tabindex="0"
+            tabIndex="0"
             id={id}
             type="checkbox"
             onChange={handleChange}

+ 2 - 2
web/src/components/Table.jsx

@@ -30,7 +30,7 @@ export function Tr({ children, className = '' }) {
 
 export function Th({ children, className = '', colspan }) {
   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}
     </th>
   );
@@ -38,7 +38,7 @@ export function Th({ children, className = '', colspan }) {
 
 export function Td({ children, className = '', colspan }) {
   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}
     </td>
   );

+ 6 - 6
web/src/components/TextField.jsx

@@ -43,12 +43,12 @@ export default function TextField({
     [onChangeText, setValue]
   );
 
-  // Reset the state if the prop value changes
   useEffect(() => {
     if (propValue !== value) {
       setValue(propValue);
     }
-  }, [propValue, setValue]);
+    // DO NOT include `value`
+  }, [propValue, setValue]); // eslint-disable-line react-hooks/exhaustive-deps
 
   const labelMoved = isFocused || value !== '';
 
@@ -62,7 +62,7 @@ export default function TextField({
       >
         <label className="flex space-x-2 items-center">
           {LeadingIcon ? (
-            <div class="w-10 h-full">
+            <div className="w-10 h-full">
               <LeadingIcon />
             </div>
           ) : null}
@@ -72,8 +72,8 @@ export default function TextField({
               onBlur={handleBlur}
               onFocus={handleFocus}
               onInput={handleChange}
-              readonly={readonly}
-              tabindex="0"
+              readOnly={readonly}
+              tabIndex="0"
               type={keyboardType}
               value={value}
               {...props}
@@ -87,7 +87,7 @@ export default function TextField({
             </div>
           </div>
           {TrailingIcon ? (
-            <div class="w-10 h-10">
+            <div className="w-10 h-10">
               <TrailingIcon />
             </div>
           ) : null}

+ 6 - 9
web/src/context/index.jsx

@@ -1,6 +1,5 @@
 import { h, createContext } from 'preact';
 import { get as getData, set as setData } from 'idb-keyval';
-import produce from 'immer';
 import { useCallback, useContext, useEffect, useLayoutEffect, useState } from 'preact/hooks';
 
 const DarkMode = createContext(null);
@@ -27,11 +26,7 @@ export function DarkModeProvider({ children }) {
     }
 
     load();
-  }, []);
-
-  if (persistedMode === null) {
-    return null;
-  }
+  }, [setDarkMode]);
 
   const handleMediaMatch = useCallback(
     ({ matches }) => {
@@ -52,7 +47,7 @@ export function DarkModeProvider({ children }) {
     const query = window.matchMedia('(prefers-color-scheme: dark)');
     query.addEventListener('change', handleMediaMatch);
     handleMediaMatch(query);
-  }, [persistedMode]);
+  }, [persistedMode, handleMediaMatch]);
 
   useLayoutEffect(() => {
     if (currentMode === 'dark') {
@@ -62,7 +57,9 @@ export function DarkModeProvider({ children }) {
     }
   }, [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() {
@@ -110,7 +107,7 @@ export function usePersistence(key, defaultValue = undefined) {
     }
 
     load();
-  }, [key]);
+  }, [key, defaultValue, setValue]);
 
   return [value, setValue, loaded];
 }

+ 17 - 24
web/src/routes/Camera.jsx

@@ -6,31 +6,26 @@ import Heading from '../components/Heading';
 import Link from '../components/Link';
 import SettingsIcon from '../icons/Settings';
 import Switch from '../components/Switch';
-import { route } from 'preact-router';
 import { usePersistence } from '../context';
-import { useCallback, useContext, useMemo, useState } from 'preact/hooks';
+import { useCallback, useMemo, useState } from 'preact/hooks';
 import { useApiHost, useConfig } from '../api';
 
+const emptyObject = Object.freeze({});
+
 export default function Camera({ camera }) {
   const { data: config } = useConfig();
   const apiHost = useApiHost();
   const [showSettings, setShowSettings] = useState(false);
 
-  if (!config) {
-    return <div>{`No camera named ${camera}`}</div>;
-  }
-
-  const cameraConfig = config.cameras[camera];
-  const [options, setOptions, optionsLoaded] = usePersistence(`${camera}-feed`, Object.freeze({}));
-
-  const objectCount = useMemo(() => cameraConfig.objects.track.length, [cameraConfig]);
+  const cameraConfig = config?.cameras[camera];
+  const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
 
   const handleSetOption = useCallback(
     (id, value) => {
       const newOptions = { ...options, [id]: value };
       setOptions(newOptions);
     },
-    [options]
+    [options, setOptions]
   );
 
   const searchParams = useMemo(
@@ -41,7 +36,7 @@ export default function Camera({ camera }) {
           return memo;
         }, [])
       ),
-    [camera, options]
+    [options]
   );
 
   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="flex space-x-3">
         <Switch checked={options['bbox']} id="bbox" onChange={handleSetOption} />
-        <span class="inline-flex">Bounding box</span>
+        <span className="inline-flex">Bounding box</span>
       </div>
       <div className="flex space-x-3">
         <Switch checked={options['timestamp']} id="timestamp" onChange={handleSetOption} />
-        <span class="inline-flex">Timestamp</span>
+        <span className="inline-flex">Timestamp</span>
       </div>
       <div className="flex space-x-3">
         <Switch checked={options['zones']} id="zones" onChange={handleSetOption} />
-        <span class="inline-flex">Zones</span>
+        <span className="inline-flex">Zones</span>
       </div>
       <div className="flex space-x-3">
         <Switch checked={options['mask']} id="mask" onChange={handleSetOption} />
-        <span class="inline-flex">Masks</span>
+        <span className="inline-flex">Masks</span>
       </div>
       <div className="flex space-x-3">
         <Switch checked={options['motion']} id="motion" onChange={handleSetOption} />
-        <span class="inline-flex">Motion boxes</span>
+        <span className="inline-flex">Motion boxes</span>
       </div>
       <div className="flex space-x-3">
         <Switch checked={options['regions']} id="regions" onChange={handleSetOption} />
-        <span class="inline-flex">Regions</span>
+        <span className="inline-flex">Regions</span>
       </div>
       <Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
     </div>
@@ -81,14 +76,12 @@ export default function Camera({ camera }) {
   return (
     <div className="space-y-4">
       <Heading size="2xl">{camera}</Heading>
-      {optionsLoaded ? (
-        <div>
-          <AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
-        </div>
-      ) : null}
+      <div>
+        <AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
+      </div>
 
       <Button onClick={handleToggleSettings} type="text">
-        <span class="w-5 h-5">
+        <span className="w-5 h-5">
           <SettingsIcon />
         </span>{' '}
         <span>{showSettings ? 'Hide' : 'Show'} Options</span>

+ 75 - 105
web/src/routes/CameraMap.jsx

@@ -1,10 +1,9 @@
 import { h } from 'preact';
-import Card from '../components/Card';
-import Button from '../components/Button';
-import Heading from '../components/Heading';
-import Switch from '../components/Switch';
-import { route } from 'preact-router';
-import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
+import Card from '../components/Card.jsx';
+import Button from '../components/Button.jsx';
+import Heading from '../components/Heading.jsx';
+import Switch from '../components/Switch.jsx';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
 import { useApiHost, useConfig } from '../api';
 
 export default function CameraMasks({ camera, url }) {
@@ -14,10 +13,6 @@ export default function CameraMasks({ camera, url }) {
   const [imageScale, setImageScale] = useState(1);
   const [snap, setSnap] = useState(true);
 
-  if (!(camera in config.cameras)) {
-    return <div>{`No camera named ${camera}`}</div>;
-  }
-
   const cameraConfig = config.cameras[camera];
   const {
     width,
@@ -38,7 +33,7 @@ export default function CameraMasks({ camera, url }) {
           }
         });
       }),
-    [camera, width, setImageScale]
+    [width, setImageScale]
   );
 
   useEffect(() => {
@@ -46,14 +41,14 @@ export default function CameraMasks({ camera, url }) {
       return;
     }
     resizeObserver.observe(imageRef.current);
-  }, [resizeObserver, imageRef.current]);
+  }, [resizeObserver, imageRef]);
 
   const [motionMaskPoints, setMotionMaskPoints] = useState(
     Array.isArray(motionMask)
       ? motionMask.map((mask) => getPolylinePoints(mask))
       : motionMask
-      ? [getPolylinePoints(motionMask)]
-      : []
+        ? [getPolylinePoints(motionMask)]
+        : []
   );
 
   const [zonePoints, setZonePoints] = useState(
@@ -67,8 +62,8 @@ export default function CameraMasks({ camera, url }) {
         [name]: Array.isArray(objectFilters[name].mask)
           ? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
           : objectFilters[name].mask
-          ? [getPolylinePoints(objectFilters[name].mask)]
-          : [],
+            ? [getPolylinePoints(objectFilters[name].mask)]
+            : [],
       }),
       {}
     )
@@ -94,26 +89,6 @@ export default function CameraMasks({ camera, url }) {
     [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
   const handleAddMask = useCallback(() => {
     const newMotionMaskPoints = [...motionMaskPoints, []];
@@ -171,11 +146,11 @@ ${motionMaskPoints.map((mask, i) => `      - ${polylinePointsToPolyline(mask)}`)
   const handleCopyZones = useCallback(async () => {
     await window.navigator.clipboard.writeText(`  zones:
 ${Object.keys(zonePoints)
-  .map(
-    (zoneName) => `    ${zoneName}:
+    .map(
+      (zoneName) => `    ${zoneName}:
       coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
-  )
-  .join('\n')}`);
+    )
+    .join('\n')}`);
   }, [zonePoints]);
 
   // Object methods
@@ -207,14 +182,14 @@ ${Object.keys(zonePoints)
     await window.navigator.clipboard.writeText(`  objects:
     filters:
 ${Object.keys(objectMaskPoints)
-  .map((objectName) =>
-    objectMaskPoints[objectName].length
-      ? `      ${objectName}:
+    .map((objectName) =>
+      objectMaskPoints[objectName].length
+        ? `      ${objectName}:
         mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
-      : ''
-  )
-  .filter(Boolean)
-  .join('\n')}`);
+        : ''
+    )
+    .filter(Boolean)
+    .join('\n')}`);
   }, [objectMaskPoints]);
 
   const handleAddToObjectMask = useCallback(
@@ -239,7 +214,7 @@ ${Object.keys(objectMaskPoints)
   );
 
   return (
-    <div class="flex-col space-y-4">
+    <div className="flex-col space-y-4">
       <Heading size="2xl">{camera} mask & zone creator</Heading>
 
       <Card
@@ -265,12 +240,12 @@ ${Object.keys(objectMaskPoints)
             height={height}
           />
         </div>
-        <div class="flex space-x-4">
+        <div className="flex space-x-4">
           <span>Snap to edges</span> <Switch checked={snap} onChange={handleChangeSnap} />
         </div>
       </div>
 
-      <div class="flex-col space-y-4">
+      <div className="flex-col space-y-4">
         <MaskValues
           editing={editing}
           title="Motion masks"
@@ -314,7 +289,7 @@ ${Object.keys(objectMaskPoints)
 }
 
 function maskYamlKeyPrefix(points) {
-  return `    - `;
+  return '    - ';
 }
 
 function zoneYamlKeyPrefix(points, key) {
@@ -323,43 +298,40 @@ function zoneYamlKeyPrefix(points, key) {
 }
 
 function objectYamlKeyPrefix(points, key, subkey) {
-  return `        - `;
+  return '        - ';
 }
 
 const MaskInset = 20;
 
-function EditableMask({ onChange, points, scale, snap, width, height }) {
-  if (!points) {
-    return null;
+function boundedSize(value, maxValue, snap) {
+  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;
+    }
   }
-  const boundingRef = useRef(null);
 
-  function boundedSize(value, 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(
     (index, newX, newY) => {
       if (newX < 0 && newY < 0) {
         return;
       }
-      let x = boundedSize(newX / scale, width, snap);
-      let y = boundedSize(newY / scale, height, snap);
+      const x = boundedSize(newX / scale, width, snap);
+      const y = boundedSize(newY / scale, height, snap);
 
       const newPoints = [...points];
       newPoints[index] = [x, y];
       onChange(newPoints);
     },
-    [scale, points, snap]
+    [height, width, onChange, scale, points, snap]
   );
 
   // 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 newPoint = [scaledX, scaledY];
 
-      let closest;
       const { index } = points.reduce(
         (result, point, i) => {
           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);
       onChange(newPoints);
     },
-    [scale, points, onChange, snap]
+    [height, width, scale, points, onChange, snap]
   );
 
   const handleRemovePoint = useCallback(
@@ -407,16 +378,16 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
       {!scaledPoints
         ? null
         : scaledPoints.map(([x, y], i) => (
-            <PolyPoint
-              boundingRef={boundingRef}
-              index={i}
-              onMove={handleMovePoint}
-              onRemove={handleRemovePoint}
-              x={x + MaskInset}
-              y={y + MaskInset}
-            />
-          ))}
-      <div className="absolute inset-0 right-0 bottom-0" onclick={handleAddPoint} ref={boundingRef} />
+          <PolyPoint
+            boundingRef={boundingRef}
+            index={i}
+            onMove={handleMovePoint}
+            onRemove={handleRemovePoint}
+            x={x + MaskInset}
+            y={y + MaskInset}
+          />
+        ))}
+      <div className="absolute inset-0 right-0 bottom-0" onClick={handleAddPoint} ref={boundingRef} />
       <svg
         width="100%"
         height="100%"
@@ -488,15 +459,15 @@ function MaskValues({
   );
 
   return (
-    <div className="overflow-hidden" onmouseover={handleMousein} onmouseout={handleMouseout}>
-      <div class="flex space-x-4">
+    <div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
+      <div className="flex space-x-4">
         <Heading className="flex-grow self-center" size="base">
           {title}
         </Heading>
         <Button onClick={onCopy}>Copy</Button>
         <Button onClick={onCreate}>Add</Button>
       </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}
         {Object.keys(points).map((mainkey) => {
           if (isMulti) {
@@ -522,20 +493,19 @@ function MaskValues({
                 ))}
               </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>
     </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, boundingRef.current]
+    [onMove, index, boundingRef]
   );
 
   const handleDragStart = useCallback(() => {
     boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
     setHidden(true);
-  }, [setHidden, boundingRef.current, handleDragOver]);
+  }, [setHidden, boundingRef, handleDragOver]);
 
   const handleDragEnd = useCallback(() => {
     boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
     setHidden(false);
-  }, [setHidden, boundingRef.current, handleDragOver]);
+  }, [setHidden, boundingRef, handleDragOver]);
 
   const handleRightClick = useCallback(
     (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`}
       style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
       draggable
-      onclick={handleClick}
-      oncontextmenu={handleRightClick}
-      ondragstart={handleDragStart}
-      ondragend={handleDragEnd}
+      onClick={handleClick}
+      onContextMenu={handleRightClick}
+      onDragStart={handleDragStart}
+      onDragEnd={handleDragEnd}
     />
   );
 }

+ 4 - 8
web/src/routes/Cameras.jsx

@@ -2,19 +2,15 @@ import { h } from 'preact';
 import ActivityIndicator from '../components/ActivityIndicator';
 import Card from '../components/Card';
 import CameraImage from '../components/CameraImage';
-import Heading from '../components/Heading';
-import { route } from 'preact-router';
-import { useConfig } from '../api';
+import { useConfig, FetchStatus } from '../api';
 import { useMemo } from 'preact/hooks';
 
 export default function Cameras() {
   const { data: config, status } = useConfig();
 
-  if (!config) {
-    return <p>loading…</p>;
-  }
-
-  return (
+  return status !== FetchStatus.LOADED ? (
+    <ActivityIndicator />
+  ) : (
     <div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
       {Object.keys(config.cameras).map((camera) => (
         <Camera name={camera} />

+ 24 - 21
web/src/routes/Debug.jsx

@@ -3,53 +3,56 @@ import ActivityIndicator from '../components/ActivityIndicator';
 import Button from '../components/Button';
 import Heading from '../components/Heading';
 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 { useCallback, useEffect, useState } from 'preact/hooks';
 
+const emptyObject = Object.freeze({});
+
 export default function Debug() {
   const config = useConfig();
 
   const [timeoutId, setTimeoutId] = useState(null);
+  const { data: stats } = useStats(null, timeoutId);
 
-  const forceUpdate = useCallback(async () => {
-    setTimeoutId(setTimeout(forceUpdate, 1000));
+  const forceUpdate = useCallback(() => {
+    const timeoutId = setTimeout(forceUpdate, 1000);
+    setTimeoutId(timeoutId);
   }, []);
 
   useEffect(() => {
     forceUpdate();
-  }, []);
+  }, [forceUpdate]);
 
   useEffect(() => {
     return () => {
       clearTimeout(timeoutId);
     };
   }, [timeoutId]);
-  const { data: stats, status } = useStats(null, timeoutId);
-
-  if (stats === null && (status === FetchStatus.LOADING || status === FetchStatus.NONE)) {
-    return <ActivityIndicator />;
-  }
-
-  const { detectors, detection_fps, service, ...cameras } = stats;
 
-  const detectorNames = Object.keys(detectors);
-  const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);
+  const { detectors, service, detection_fps, ...cameras } = stats || emptyObject;
 
-  const cameraNames = Object.keys(cameras);
-  const cameraDataKeys = Object.keys(cameras[cameraNames[0]]);
+  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 handleCopyConfig = useCallback(async () => {
-    await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
+  const handleCopyConfig = useCallback(() => {
+    async function copy() {
+      await window.navigator.clipboard.writeText(JSON.stringify(config, null, 2));
+    }
+    copy();
   }, [config]);
 
-  return (
-    <div class="space-y-4">
+  return stats === null ? (
+    <ActivityIndicator />
+  ) : (
+    <div className="space-y-4">
       <Heading>
         Debug <span className="text-sm">{service.version}</span>
       </Heading>
 
-      <div class="min-w-0 overflow-auto">
+      <div className="min-w-0 overflow-auto">
         <Table className="w-full">
           <Thead>
             <Tr>
@@ -72,7 +75,7 @@ export default function Debug() {
         </Table>
       </div>
 
-      <div class="min-w-0 overflow-auto">
+      <div className="min-w-0 overflow-auto">
         <Table className="w-full">
           <Thead>
             <Tr>

+ 2 - 2
web/src/routes/Event.jsx

@@ -3,7 +3,7 @@ import ActivityIndicator from '../components/ActivityIndicator';
 import Heading from '../components/Heading';
 import Link from '../components/Link';
 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 }) {
   const apiHost = useApiHost();
@@ -54,7 +54,7 @@ export default function Event({ eventId }) {
       {data.has_clip ? (
         <Fragment>
           <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>
       ) : (
         <p>No clip available</p>

+ 29 - 30
web/src/routes/Events.jsx

@@ -1,6 +1,5 @@
 import { h } from 'preact';
 import ActivityIndicator from '../components/ActivityIndicator';
-import Card from '../components/Card';
 import Heading from '../components/Heading';
 import Link from '../components/Link';
 import Select from '../components/Select';
@@ -8,39 +7,39 @@ import produce from 'immer';
 import { route } from 'preact-router';
 import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
 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 initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
 const reducer = (state = initialState, action) => {
   switch (action.type) {
-    case 'APPEND_EVENTS': {
-      const {
-        meta: { searchString },
-        payload,
-      } = action;
-      return produce(state, (draftState) => {
-        draftState.searchStrings[searchString] = true;
-        draftState.events.push(...payload);
-      });
-    }
+  case 'APPEND_EVENTS': {
+    const {
+      meta: { searchString },
+      payload,
+    } = action;
+    return produce(state, (draftState) => {
+      draftState.searchStrings[searchString] = true;
+      draftState.events.push(...payload);
+    });
+  }
 
-    case 'REACHED_END': {
-      const {
-        meta: { searchString },
-      } = action;
-      return produce(state, (draftState) => {
-        draftState.reachedEnd = true;
-        draftState.searchStrings[searchString] = true;
-      });
-    }
+  case 'REACHED_END': {
+    const {
+      meta: { searchString },
+    } = action;
+    return produce(state, (draftState) => {
+      draftState.reachedEnd = true;
+      draftState.searchStrings[searchString] = true;
+    });
+  }
 
-    case 'RESET':
-      return initialState;
+  case 'RESET':
+    return initialState;
 
-    default:
-      return state;
+  default:
+    return state;
   }
 };
 
@@ -65,7 +64,7 @@ export default function Events({ path: pathname } = {}) {
     if (Array.isArray(data) && data.length < API_LIMIT) {
       dispatch({ type: 'REACHED_END', meta: { searchString } });
     }
-  }, [data]);
+  }, [data, searchString, searchStrings]);
 
   const observer = useRef(
     new IntersectionObserver((entries, observer) => {
@@ -96,7 +95,7 @@ export default function Events({ path: pathname } = {}) {
         }
       }
     },
-    [observer.current, reachedEnd]
+    [observer, reachedEnd]
   );
 
   const handleFilter = useCallback(
@@ -121,7 +120,7 @@ export default function Events({ path: pathname } = {}) {
         <Table className="min-w-full table-fixed">
           <Thead>
             <Tr>
-              <Th></Th>
+              <Th />
               <Th>Camera</Th>
               <Th>Label</Th>
               <Th>Score</Th>
@@ -213,7 +212,7 @@ function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
     params.set(paramName, name);
     removeDefaultSearchKeys(params);
     return `${pathname}?${params.toString()}`;
-  }, [searchParams]);
+  }, [searchParams, paramName, pathname, name]);
 
   const handleClick = useCallback(
     (event) => {
@@ -223,7 +222,7 @@ function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
       params.set(paramName, name);
       onFilter(params);
     },
-    [href, searchParams]
+    [href, searchParams, onFilter, paramName, name]
   );
 
   return (

+ 6 - 7
web/src/routes/StyleGuide.jsx

@@ -1,7 +1,6 @@
 import { h } from 'preact';
 import ArrowDropdown from '../icons/ArrowDropdown';
 import ArrowDropup from '../icons/ArrowDropup';
-import Card from '../components/Card';
 import Button from '../components/Button';
 import Heading from '../components/Heading';
 import Select from '../components/Select';
@@ -22,13 +21,13 @@ export default function StyleGuide() {
   return (
     <div>
       <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 color="red">Danger</Button>
         <Button color="green">Save</Button>
         <Button disabled>Disabled</Button>
       </div>
-      <div class="flex space-x-4 mb-4">
+      <div className="flex space-x-4 mb-4">
         <Button type="text">Default</Button>
         <Button color="red" type="text">
           Danger
@@ -40,7 +39,7 @@ export default function StyleGuide() {
           Disabled
         </Button>
       </div>
-      <div class="flex space-x-4 mb-4">
+      <div className="flex space-x-4 mb-4">
         <Button type="outlined">Default</Button>
         <Button color="red" type="outlined">
           Danger
@@ -54,7 +53,7 @@ export default function StyleGuide() {
       </div>
 
       <Heading size="md">Switch</Heading>
-      <div class="flex">
+      <div className="flex">
         <div>
           <p>Disabled, off</p>
           <Switch />
@@ -74,12 +73,12 @@ export default function StyleGuide() {
       </div>
 
       <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" />
       </div>
 
       <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="Pre-filled" value="This is my pre-filled value" />
         <TextField label="With help" helpText="This is some help text" />

+ 0 - 2
web/tailwind.config.js

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است