diff --git a/.gitignore b/.gitignore index 8de9cbdf9..6fe616b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ web/build web/node_modules web/coverage core +!/web/**/*.ts diff --git a/docs/docs/configuration/user_interface.md b/docs/docs/configuration/user_interface.md new file mode 100644 index 000000000..72ce5a5d6 --- /dev/null +++ b/docs/docs/configuration/user_interface.md @@ -0,0 +1,15 @@ +--- +id: user_interface +title: User Interface Configurations +--- + +### Experimental UI + +While developing and testing new components, users may decide to opt-in to test potential new features on the front-end. + +```yaml +ui: + use_experimental: true +``` + +Note that experimental changes may contain bugs or may be removed at any time in future releases of the software. Use of these features are presented as-is and with no functional guarantee. diff --git a/frigate/config.py b/frigate/config.py index c210713a2..a81f3241a 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -43,6 +43,8 @@ class DetectorConfig(FrigateBaseModel): device: str = Field(default="usb", title="Device Type") num_threads: int = Field(default=3, title="Number of detection threads") +class UIConfig(FrigateBaseModel): + use_experimental: bool = Field(default=False, title="Experimental UI") class MqttConfig(FrigateBaseModel): host: str = Field(title="MQTT Host") @@ -709,6 +711,7 @@ class FrigateConfig(FrigateBaseModel): environment_vars: Dict[str, str] = Field( default_factory=dict, title="Frigate environment variables." ) + ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") model: ModelConfig = Field( default_factory=ModelConfig, title="Detection model configuration." ) diff --git a/web/.eslintrc.js b/web/.eslintrc.js index c8b576cab..d184d1840 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { 'prettier', 'preact', 'plugin:import/react', + 'plugin:import/typescript', 'plugin:testing-library/recommended', 'plugin:jest/recommended', ], @@ -137,4 +138,20 @@ module.exports = { }, }, }, + + overrides: [ + { + files: ['*.{ts,tsx}'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: ['plugin:@typescript-eslint/recommended'], + settings: { + 'import/resolver': { + node: { + extensions: ['.ts', '.tsx'], + }, + }, + }, + }, + ], }; diff --git a/web/babel.config.js b/web/babel.config.js index fbdf92e2e..8cd0c50e1 100644 --- a/web/babel.config.js +++ b/web/babel.config.js @@ -1,4 +1,4 @@ module.exports = { - presets: ['@babel/preset-env'], + presets: ['@babel/preset-env', ['@babel/typescript', { jsxPragma: 'h' }]], plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'h' }]], }; diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 000000000..92a1a27a7 --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2019", + "jsx": "preserve", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 2eee8ca2d..e277967a1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15109,6 +15109,33 @@ "regexpu-core": "^4.7.1" } }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/helper-explode-assignable-expression": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.13.tgz", @@ -15831,6 +15858,23 @@ } } }, + "@babel/plugin-syntax-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz", + "integrity": "sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true + } + } + }, "@babel/plugin-transform-arrow-functions": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.13.tgz", @@ -17463,6 +17507,206 @@ } } }, + "@babel/plugin-transform-typescript": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", + "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-typescript": "^7.16.7" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/generator": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.3.tgz", + "integrity": "sha512-+R6Dctil/MgUsZsZAkYgK+ADNSZzJRRy0TvY65T71z/CR854xHQ1EweBYXdfT+HNeN7w0cSJJEzgxZMv40pxsg==", + "dev": true, + "requires": { + "@babel/types": "^7.17.0", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.17.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.1.tgz", + "integrity": "sha512-JBdSr/LtyYIno/pNnJ75lBcqc3Z1XXujzPanHqjvvrhOA+DTceTFuJi8XjmWTZh4r3fsdfqaCMN0iZemdkxZHQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-member-expression-to-functions": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz", + "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz", + "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.7.tgz", + "integrity": "sha512-VtJ/65tYiU/6AbMTDwyoXGPKHgTsfRarivm+YbB5uAzKUyuPjgZSgAFeG87FCigc7KNHu2Pegh1XIT3lXjvz3Q==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", + "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", + "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-member-expression-to-functions": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", + "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.3.tgz", + "integrity": "sha512-7yJPvPV+ESz2IUTPbOL+YkIGyCqOyNIzdguKQuJGnH7bg1WTIifuM21YqokFt/THWh1AkCRn9IgoykTRCBVpzA==", + "dev": true + }, + "@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/traverse": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz", + "integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.3", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.3", + "@babel/types": "^7.17.0", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, "@babel/plugin-transform-unicode-escapes": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz", @@ -17628,6 +17872,31 @@ "esutils": "^2.0.2" } }, + "@babel/preset-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", + "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-typescript": "^7.16.7" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true + } + } + }, "@babel/runtime": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz", @@ -18823,6 +19092,102 @@ "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==", "dev": true }, + "@typescript-eslint/eslint-plugin": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.12.0.tgz", + "integrity": "sha512-fwCMkDimwHVeIOKeBHiZhRUfJXU8n6xW1FL9diDxAyGAFvKcH4csy0v7twivOQdQdA0KC8TDr7GGRd3L4Lv0rQ==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.12.0", + "@typescript-eslint/type-utils": "5.12.0", + "@typescript-eslint/utils": "5.12.0", + "debug": "^4.3.2", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.1.8", + "regexpp": "^3.2.0", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.12.0.tgz", + "integrity": "sha512-GAMobtIJI8FGf1sLlUWNUm2IOkIjvn7laFWyRx7CLrv6nLBI7su+B7lbStqVlK5NdLvHRFiJo2HhiDF7Ki01WQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/visitor-keys": "5.12.0" + } + }, + "@typescript-eslint/types": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.12.0.tgz", + "integrity": "sha512-JowqbwPf93nvf8fZn5XrPGFBdIK8+yx5UEGs2QFAYFI8IWYfrzz+6zqlurGr2ctShMaJxqwsqmra3WXWjH1nRQ==", + "dev": true + }, + "@typescript-eslint/visitor-keys": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.12.0.tgz", + "integrity": "sha512-cFwTlgnMV6TgezQynx2c/4/tx9Tufbuo9LPzmWqyRC3QC4qTGkAG1C6pBr0/4I10PAI/FlYunI3vJjIcu+ZHMg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "eslint-visitor-keys": "^3.0.0" + } + }, + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, "@typescript-eslint/experimental-utils": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.0.tgz", @@ -18837,6 +19202,164 @@ "eslint-utils": "^2.0.0" } }, + "@typescript-eslint/parser": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.12.0.tgz", + "integrity": "sha512-MfSwg9JMBojMUoGjUmX+D2stoQj1CBYTCP0qnnVtu9A+YQXVKNtLjasYh+jozOcrb/wau8TCfWOkQTiOAruBog==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.12.0", + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/typescript-estree": "5.12.0", + "debug": "^4.3.2" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.12.0.tgz", + "integrity": "sha512-GAMobtIJI8FGf1sLlUWNUm2IOkIjvn7laFWyRx7CLrv6nLBI7su+B7lbStqVlK5NdLvHRFiJo2HhiDF7Ki01WQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/visitor-keys": "5.12.0" + } + }, + "@typescript-eslint/types": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.12.0.tgz", + "integrity": "sha512-JowqbwPf93nvf8fZn5XrPGFBdIK8+yx5UEGs2QFAYFI8IWYfrzz+6zqlurGr2ctShMaJxqwsqmra3WXWjH1nRQ==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.12.0.tgz", + "integrity": "sha512-Dd9gVeOqt38QHR0BEA8oRaT65WYqPYbIc5tRFQPkfLquVEFPD1HAtbZT98TLBkEcCkvwDYOAvuSvAD9DnQhMfQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/visitor-keys": "5.12.0", + "debug": "^4.3.2", + "globby": "^11.0.4", + "is-glob": "^4.0.3", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.12.0.tgz", + "integrity": "sha512-cFwTlgnMV6TgezQynx2c/4/tx9Tufbuo9LPzmWqyRC3QC4qTGkAG1C6pBr0/4I10PAI/FlYunI3vJjIcu+ZHMg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "eslint-visitor-keys": "^3.0.0" + } + }, + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, "@typescript-eslint/scope-manager": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.15.0.tgz", @@ -18847,6 +19370,49 @@ "@typescript-eslint/visitor-keys": "4.15.0" } }, + "@typescript-eslint/type-utils": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.12.0.tgz", + "integrity": "sha512-9j9rli3zEBV+ae7rlbBOotJcI6zfc6SHFMdKI9M3Nc0sy458LJ79Os+TPWeBBL96J9/e36rdJOfCuyRSgFAA0Q==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "5.12.0", + "debug": "^4.3.2", + "tsutils": "^3.21.0" + }, + "dependencies": { + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, "@typescript-eslint/types": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.15.0.tgz", @@ -18868,6 +19434,189 @@ "tsutils": "^3.17.1" } }, + "@typescript-eslint/utils": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.12.0.tgz", + "integrity": "sha512-k4J2WovnMPGI4PzKgDtQdNrCnmBHpMUFy21qjX2CoPdoBcSBIMvVBr9P2YDP8jOqZOeK3ThOL6VO/sy6jtnvzw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.12.0", + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/typescript-estree": "5.12.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "dependencies": { + "@types/json-schema": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", + "dev": true + }, + "@typescript-eslint/scope-manager": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.12.0.tgz", + "integrity": "sha512-GAMobtIJI8FGf1sLlUWNUm2IOkIjvn7laFWyRx7CLrv6nLBI7su+B7lbStqVlK5NdLvHRFiJo2HhiDF7Ki01WQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/visitor-keys": "5.12.0" + } + }, + "@typescript-eslint/types": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.12.0.tgz", + "integrity": "sha512-JowqbwPf93nvf8fZn5XrPGFBdIK8+yx5UEGs2QFAYFI8IWYfrzz+6zqlurGr2ctShMaJxqwsqmra3WXWjH1nRQ==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.12.0.tgz", + "integrity": "sha512-Dd9gVeOqt38QHR0BEA8oRaT65WYqPYbIc5tRFQPkfLquVEFPD1HAtbZT98TLBkEcCkvwDYOAvuSvAD9DnQhMfQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "@typescript-eslint/visitor-keys": "5.12.0", + "debug": "^4.3.2", + "globby": "^11.0.4", + "is-glob": "^4.0.3", + "semver": "^7.3.5", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.12.0.tgz", + "integrity": "sha512-cFwTlgnMV6TgezQynx2c/4/tx9Tufbuo9LPzmWqyRC3QC4qTGkAG1C6pBr0/4I10PAI/FlYunI3vJjIcu+ZHMg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.12.0", + "eslint-visitor-keys": "^3.0.0" + } + }, + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, "@typescript-eslint/visitor-keys": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.0.tgz", diff --git a/web/package.json b/web/package.json index e0eaa373a..63c35807a 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "prebuild": "rimraf 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", + "lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts", "test": "jest" }, "dependencies": { @@ -26,11 +26,14 @@ "@babel/eslint-parser": "^7.12.13", "@babel/plugin-transform-react-jsx": "^7.12.13", "@babel/preset-env": "^7.12.13", + "@babel/preset-typescript": "^7.16.7", "@prefresh/snowpack": "^3.0.1", "@snowpack/plugin-postcss": "^1.1.0", "@testing-library/jest-dom": "^5.11.9", "@testing-library/preact": "^2.0.1", "@testing-library/user-event": "^12.7.1", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", "autoprefixer": "^10.2.1", "cross-env": "^7.0.3", "eslint": "^7.19.0", diff --git a/web/prettier.config.js b/web/prettier.config.js index 7a1273f07..b1853a124 100644 --- a/web/prettier.config.js +++ b/web/prettier.config.js @@ -1,5 +1,6 @@ module.exports = { printWidth: 120, singleQuote: true, + jsxSingleQuote: true, useTabs: false, }; diff --git a/web/public/marker.png b/web/public/marker.png new file mode 100644 index 000000000..3591e0aec Binary files /dev/null and b/web/public/marker.png differ diff --git a/web/src/App.jsx b/web/src/App.jsx index f6dd29451..850be62bb 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -10,7 +10,8 @@ import { DarkModeProvider, DrawerProvider } from './context'; import { FetchStatus, useConfig } from './api'; export default function App() { - const { status } = useConfig(); + const { status, data: config } = useConfig(); + const cameraComponent = config && config.ui.use_experimental ? Routes.getCameraV2 : Routes.getCamera; return ( @@ -23,10 +24,10 @@ export default function App() { ) : (
-
+
- + diff --git a/web/src/components/AutoUpdatingCameraImage.jsx b/web/src/components/AutoUpdatingCameraImage.jsx index b5cd2e8d0..9cc598cd4 100644 --- a/web/src/components/AutoUpdatingCameraImage.jsx +++ b/web/src/components/AutoUpdatingCameraImage.jsx @@ -4,7 +4,7 @@ import { useCallback, useState } from 'preact/hooks'; const MIN_LOAD_TIMEOUT_MS = 200; -export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true }) { +export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true, className }) { const [key, setKey] = useState(Date.now()); const [fps, setFps] = useState(0); @@ -20,7 +20,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams = '', sho }, [key, setFps]); return ( -
+
{showFps ? Displaying at {fps}fps : null}
diff --git a/web/src/components/BubbleButton.tsx b/web/src/components/BubbleButton.tsx new file mode 100644 index 000000000..87705eb7f --- /dev/null +++ b/web/src/components/BubbleButton.tsx @@ -0,0 +1,45 @@ +import { h } from 'preact'; + +interface BubbleButtonProps { + variant?: 'primary' | 'secondary'; + children?: preact.JSX.Element; + disabled?: boolean; + className?: string; + onClick?: () => void; +} + +export const BubbleButton = ({ + variant = 'primary', + children, + onClick, + disabled = false, + className = '', +}: BubbleButtonProps) => { + const BASE_CLASS = 'rounded-full px-4 py-2'; + const PRIMARY_CLASS = 'text-white bg-blue-500 dark:text-black dark:bg-white'; + const SECONDARY_CLASS = 'text-black dark:text-white bg-transparent'; + let computedClass = BASE_CLASS; + + if (disabled) { + computedClass += ' text-gray-200 dark:text-gray-200'; + } else if (variant === 'primary') { + computedClass += ` ${PRIMARY_CLASS}`; + } else if (variant === 'secondary') { + computedClass += ` ${SECONDARY_CLASS}`; + } + + const onClickHandler = () => { + if (disabled) { + return; + } + + if (onClick) { + onClick(); + } + }; + return ( + + ); +}; diff --git a/web/src/components/DebugCamera.jsx b/web/src/components/DebugCamera.jsx new file mode 100644 index 000000000..9c4d67f73 --- /dev/null +++ b/web/src/components/DebugCamera.jsx @@ -0,0 +1,74 @@ +import { h } from 'preact'; +import Link from './Link'; +import Switch from './Switch'; +import { useCallback, useMemo } from 'preact/hooks'; +import { usePersistence } from '../context'; +import AutoUpdatingCameraImage from './AutoUpdatingCameraImage'; + +const emptyObject = Object.freeze({}); + +export function DebugCamera({ camera }) { + const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject); + + const handleSetOption = useCallback( + (id, value) => { + const newOptions = { ...options, [id]: value }; + setOptions(newOptions); + }, + [options, setOptions] + ); + + const searchParams = useMemo( + () => + new URLSearchParams( + Object.keys(options).reduce((memo, key) => { + memo.push([key, options[key] === true ? '1' : '0']); + return memo; + }, []) + ), + [options] + ); + + const optionContent = ( +
+ + + + + + + Mask & Zone creator +
+ ); + + return ( +
+ + {optionContent} +
+ ); +} diff --git a/web/src/components/Dialog.jsx b/web/src/components/Dialog.jsx index 472dc3e92..ad4f57d72 100644 --- a/web/src/components/Dialog.jsx +++ b/web/src/components/Dialog.jsx @@ -1,10 +1,8 @@ import { h, Fragment } from 'preact'; -import Button from './Button'; -import Heading from './Heading'; import { createPortal } from 'preact/compat'; import { useState, useEffect } from 'preact/hooks'; -export default function Dialog({ actions = [], portalRootID = 'dialogs', title, text }) { +export default function Dialog({ children, portalRootID = 'dialogs' }) { const portalRoot = portalRootID && document.getElementById(portalRootID); const [show, setShow] = useState(false); @@ -27,17 +25,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title, show ? 'scale-100 opacity-100' : '' }`} > -
- {title} -

{text}

-
-
- {actions.map(({ color, text, onClick, ...props }, i) => ( - - ))} -
+ {children}
diff --git a/web/src/components/HistoryViewer/HistoryHeader.tsx b/web/src/components/HistoryViewer/HistoryHeader.tsx new file mode 100644 index 000000000..5178eaada --- /dev/null +++ b/web/src/components/HistoryViewer/HistoryHeader.tsx @@ -0,0 +1,30 @@ +import { h } from 'preact'; +import Heading from '../Heading'; +import { TimelineEvent } from '../Timeline/Timeline'; + +interface HistoryHeaderProps { + event: TimelineEvent; + className?: string; +} +export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => { + let title = 'No Event Found'; + let subtitle = Event was not found at marker position.; + if (event) { + const { startTime, endTime, label } = event; + const thisMorning = new Date(); + thisMorning.setHours(0, 0, 0); + const isToday = endTime.getTime() > thisMorning.getTime(); + title = label; + subtitle = ( + + {isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} · + + ); + } + return ( +
+ {title} +
{subtitle}
+
+ ); +}; diff --git a/web/src/components/HistoryViewer/HistoryVideo.tsx b/web/src/components/HistoryViewer/HistoryVideo.tsx new file mode 100644 index 000000000..8723caccb --- /dev/null +++ b/web/src/components/HistoryViewer/HistoryVideo.tsx @@ -0,0 +1,134 @@ +import { h } from 'preact'; +import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; +import { useApiHost } from '../../api'; +import { isNullOrUndefined } from '../../utils/objectUtils'; + +interface OnTimeUpdateEvent { + timestamp: number; + isPlaying: boolean; +} + +interface VideoProperties { + posterUrl: string; + videoUrl: string; + height: number; +} + +interface HistoryVideoProps { + id: string; + isPlaying: boolean; + currentTime: number; + onTimeUpdate?: (event: OnTimeUpdateEvent) => void; + onPause: () => void; + onPlay: () => void; +} + +export const HistoryVideo = ({ + id, + isPlaying: videoIsPlaying, + currentTime, + onTimeUpdate, + onPause, + onPlay, +}: HistoryVideoProps) => { + const apiHost = useApiHost(); + const videoRef = useRef(); + const [videoHeight, setVideoHeight] = useState(undefined); + const [videoProperties, setVideoProperties] = useState(undefined); + + const currentVideo = videoRef.current; + if (currentVideo && !videoHeight) { + const currentVideoHeight = currentVideo.offsetHeight; + if (currentVideoHeight > 0) { + setVideoHeight(currentVideoHeight); + } + } + + useEffect(() => { + const idExists = !isNullOrUndefined(id); + if (idExists) { + if (videoRef.current && !videoRef.current.paused) { + videoRef.current = undefined; + } + + setVideoProperties({ + posterUrl: `${apiHost}/api/events/${id}/snapshot.jpg`, + videoUrl: `${apiHost}/vod/event/${id}/index.m3u8`, + height: videoHeight, + }); + } else { + setVideoProperties(undefined); + } + }, [id, videoHeight, videoRef, apiHost]); + + useEffect(() => { + const playVideo = (video: HTMLMediaElement) => video.play(); + + const attemptPlayVideo = (video: HTMLMediaElement) => { + const videoHasNotLoaded = video.readyState <= 1; + if (videoHasNotLoaded) { + video.oncanplay = () => { + playVideo(video); + }; + video.load(); + } else { + playVideo(video); + } + }; + + const video = videoRef.current; + const videoExists = !isNullOrUndefined(video); + if (videoExists) { + if (videoIsPlaying) { + attemptPlayVideo(video); + } else { + video.pause(); + } + } + }, [videoIsPlaying, videoRef]); + + useEffect(() => { + const video = videoRef.current; + const videoExists = !isNullOrUndefined(video); + const hasSeeked = currentTime >= 0; + if (videoExists && hasSeeked) { + video.currentTime = currentTime; + } + }, [currentTime, videoRef]); + + const onTimeUpdateHandler = useCallback( + (event: Event) => { + const target = event.target as HTMLMediaElement; + const timeUpdateEvent = { + isPlaying: videoIsPlaying, + timestamp: target.currentTime, + }; + + onTimeUpdate && onTimeUpdate(timeUpdateEvent); + }, + [videoIsPlaying, onTimeUpdate] + ); + + const videoPropertiesIsUndefined = isNullOrUndefined(videoProperties); + if (videoPropertiesIsUndefined) { + return
; + } + + const { posterUrl, videoUrl, height } = videoProperties; + return ( + + ); +}; diff --git a/web/src/components/HistoryViewer/HistoryViewer.tsx b/web/src/components/HistoryViewer/HistoryViewer.tsx new file mode 100644 index 000000000..1b811498a --- /dev/null +++ b/web/src/components/HistoryViewer/HistoryViewer.tsx @@ -0,0 +1,79 @@ +import { Fragment, h } from 'preact'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { useEvents } from '../../api'; +import { useSearchString } from '../../hooks/useSearchString'; +import { getNowYesterdayInLong } from '../../utils/dateUtil'; +import Timeline from '../Timeline/Timeline'; +import { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent'; +import { TimelineEvent } from '../Timeline/TimelineEvent'; +import { HistoryHeader } from './HistoryHeader'; +import { HistoryVideo } from './HistoryVideo'; + +export default function HistoryViewer({ camera }) { + const { searchString } = useSearchString(500, `camera=${camera}&after=${getNowYesterdayInLong()}`); + const { data: events } = useEvents(searchString); + + const [timelineEvents, setTimelineEvents] = useState(undefined); + const [currentEvent, setCurrentEvent] = useState(undefined); + const [isPlaying, setIsPlaying] = useState(undefined); + const [currentTime, setCurrentTime] = useState(undefined); + + useEffect(() => { + if (events) { + const filteredEvents = [...events].reverse().filter((e) => e.end_time !== undefined); + setTimelineEvents(filteredEvents); + } + }, [events]); + + const handleTimelineChange = useCallback( + (event: TimelineChangeEvent) => { + if (event.seekComplete) { + setCurrentEvent(event.timelineEvent); + + if (isPlaying && event.timelineEvent) { + const eventTime = (event.markerTime.getTime() - event.timelineEvent.startTime.getTime()) / 1000; + setCurrentTime(eventTime); + } + } + }, + [isPlaying] + ); + + const onPlayHandler = () => { + setIsPlaying(true); + }; + + const onPausedHandler = () => { + setIsPlaying(false); + }; + + const onPlayPauseHandler = (isPlaying: boolean) => { + setIsPlaying(isPlaying); + }; + + return ( + + +
+ + + + +
+
+ + +
+ ); +} diff --git a/web/src/components/LiveChip.jsx b/web/src/components/LiveChip.jsx new file mode 100644 index 000000000..5173cdd53 --- /dev/null +++ b/web/src/components/LiveChip.jsx @@ -0,0 +1,18 @@ +import { h } from 'preact'; + +export function LiveChip({ className }) { + return ( +
+
+ + + + +
+ Live +
+ ); +} diff --git a/web/src/components/Prompt.jsx b/web/src/components/Prompt.jsx new file mode 100644 index 000000000..689fc0fdf --- /dev/null +++ b/web/src/components/Prompt.jsx @@ -0,0 +1,22 @@ +import { h } from 'preact'; +import Button from './Button'; +import Heading from './Heading'; +import Dialog from './Dialog'; + +export default function Prompt({ actions = [], title, text }) { + return ( + +
+ {title} +

{text}

+
+
+ {actions.map(({ color, text, onClick, ...props }, i) => ( + + ))} +
+
+ ); +} diff --git a/web/src/components/Tabs.jsx b/web/src/components/Tabs.jsx new file mode 100644 index 000000000..800375c31 --- /dev/null +++ b/web/src/components/Tabs.jsx @@ -0,0 +1,39 @@ +import { h } from 'preact'; +import { useCallback, useState } from 'preact/hooks'; + +export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) { + const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp); + + const handleSelected = useCallback( + (index) => () => { + setSelectedIndex(index); + onChange && onChange(index); + }, + [onChange] + ); + + const RenderChildren = useCallback(() => { + return children.map((child, i) => { + child.props.selected = i === selectedIndex; + child.props.onClick = handleSelected(i); + return child; + }); + }, [selectedIndex, children, handleSelected]); + + return ( +
+ +
+ ); +} + +export function TextTab({ selected, text, onClick }) { + const selectedStyle = selected + ? 'text-white bg-blue-500 dark:text-black dark:bg-white' + : 'text-black dark:text-white bg-transparent'; + return ( + + ); +} diff --git a/web/src/components/Timeline/ScrollPermission.ts b/web/src/components/Timeline/ScrollPermission.ts new file mode 100644 index 000000000..c0fe084c9 --- /dev/null +++ b/web/src/components/Timeline/ScrollPermission.ts @@ -0,0 +1,4 @@ +export interface ScrollPermission { + allowed: boolean; + resetAfterSeeked: boolean; +} \ No newline at end of file diff --git a/web/src/components/Timeline/Timeline.tsx b/web/src/components/Timeline/Timeline.tsx new file mode 100644 index 000000000..0f01be9c7 --- /dev/null +++ b/web/src/components/Timeline/Timeline.tsx @@ -0,0 +1,242 @@ +import { Fragment, h } from 'preact'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils'; +import { ScrollPermission } from './ScrollPermission'; +import { TimelineBlocks } from './TimelineBlocks'; +import { TimelineChangeEvent } from './TimelineChangeEvent'; +import { DisabledControls, TimelineControls } from './TimelineControls'; +import { TimelineEvent } from './TimelineEvent'; +import { TimelineEventBlock } from './TimelineEventBlock'; + +interface TimelineProps { + events: TimelineEvent[]; + isPlaying: boolean; + onChange: (event: TimelineChangeEvent) => void; + onPlayPause?: (isPlaying: boolean) => void; +} + +export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) { + const timelineContainerRef = useRef(undefined); + + const [timeline, setTimeline] = useState([]); + const [disabledControls, setDisabledControls] = useState({ + playPause: false, + next: true, + previous: false, + }); + const [timelineOffset, setTimelineOffset] = useState(undefined); + const [markerTime, setMarkerTime] = useState(undefined); + const [currentEvent, setCurrentEvent] = useState(undefined); + const [scrollTimeout, setScrollTimeout] = useState(undefined); + const [scrollPermission, setScrollPermission] = useState({ + allowed: true, + resetAfterSeeked: false, + }); + + const scrollToPosition = useCallback( + (positionX: number) => { + if (timelineContainerRef.current) { + const permission: ScrollPermission = { + allowed: true, + resetAfterSeeked: true, + }; + setScrollPermission(permission); + timelineContainerRef.current.scroll({ + left: positionX, + behavior: 'smooth', + }); + } + }, + [timelineContainerRef] + ); + + const scrollToEvent = useCallback( + (event, offset = 0) => { + scrollToPosition(event.positionX + offset - timelineOffset); + }, + [timelineOffset, scrollToPosition] + ); + + useEffect(() => { + if (timeline.length > 0 && currentEvent) { + const currentIndex = currentEvent.index; + if (currentIndex === 0) { + setDisabledControls((previous) => ({ + ...previous, + next: false, + previous: true, + })); + } else if (currentIndex === timeline.length - 1) { + setDisabledControls((previous) => ({ + ...previous, + previous: false, + next: true, + })); + } else { + setDisabledControls((previous) => ({ + ...previous, + previous: false, + next: false, + })); + } + } + }, [timeline, currentEvent]); + + useEffect(() => { + if (events && events.length > 0 && timelineOffset) { + const timelineEvents = getTimelineEventBlocksFromTimelineEvents(events, timelineOffset); + const lastEventIndex = timelineEvents.length - 1; + const recentEvent = timelineEvents[lastEventIndex]; + + setTimeline(timelineEvents); + setMarkerTime(recentEvent.startTime); + setCurrentEvent(recentEvent); + scrollToEvent(recentEvent); + } + }, [events, timelineOffset, scrollToEvent]); + + useEffect(() => { + const timelineIsLoaded = timeline.length > 0; + if (timelineIsLoaded) { + const lastEvent = timeline[timeline.length - 1]; + scrollToEvent(lastEvent); + } + }, [timeline, scrollToEvent]); + + const checkMarkerForEvent = (markerTime: Date) => { + const adjustedMarkerTime = new Date(markerTime); + adjustedMarkerTime.setSeconds(markerTime.getSeconds() + 1); + + return [...timeline] + .reverse() + .find( + (timelineEvent) => + timelineEvent.startTime.getTime() <= adjustedMarkerTime.getTime() && + timelineEvent.endTime.getTime() >= adjustedMarkerTime.getTime() + ); + }; + + const seekCompleteHandler = (markerTime: Date) => { + if (scrollPermission.allowed) { + const markerEvent = checkMarkerForEvent(markerTime); + setCurrentEvent(markerEvent); + + onChange({ + markerTime, + timelineEvent: markerEvent, + seekComplete: true, + }); + } + + if (scrollPermission.resetAfterSeeked) { + setScrollPermission({ + allowed: true, + resetAfterSeeked: false, + }); + } + }; + + const waitForSeekComplete = (markerTime: Date) => { + clearTimeout(scrollTimeout); + setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150)); + }; + + const onTimelineScrollHandler = () => { + if (timelineContainerRef.current && timeline.length > 0) { + const currentMarkerTime = getCurrentMarkerTime(); + setMarkerTime(currentMarkerTime); + waitForSeekComplete(currentMarkerTime); + onChange({ + timelineEvent: currentEvent, + markerTime: currentMarkerTime, + seekComplete: false, + }); + } + }; + + const getCurrentMarkerTime = useCallback(() => { + if (timelineContainerRef.current && timeline.length > 0) { + const scrollPosition = timelineContainerRef.current.scrollLeft; + const firstTimelineEvent = timeline[0] as TimelineEventBlock; + const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime(); + return new Date(firstTimelineEventStartTime + scrollPosition * 1000); + } + }, [timeline, timelineContainerRef]); + + useEffect(() => { + if (timelineContainerRef) { + const timelineContainerWidth = timelineContainerRef.current.offsetWidth; + const offset = Math.round(timelineContainerWidth / 2); + setTimelineOffset(offset); + } + }, [timelineContainerRef]); + + const handleViewEvent = useCallback( + (event: TimelineEventBlock) => { + scrollToEvent(event); + setMarkerTime(getCurrentMarkerTime()); + }, + [scrollToEvent, getCurrentMarkerTime] + ); + + const onPlayPauseHandler = (isPlaying: boolean) => { + onPlayPause(isPlaying); + }; + + const onPreviousHandler = () => { + if (currentEvent) { + const previousEvent = timeline[currentEvent.index - 1]; + setCurrentEvent(previousEvent); + scrollToEvent(previousEvent); + } + }; + const onNextHandler = () => { + if (currentEvent) { + const nextEvent = timeline[currentEvent.index + 1]; + setCurrentEvent(nextEvent); + scrollToEvent(nextEvent); + } + }; + + const timelineBlocks = useMemo(() => { + if (timelineOffset && timeline.length > 0) { + return ; + } + }, [timeline, timelineOffset, handleViewEvent]); + + return ( + +
+
+ + {markerTime && {markerTime.toLocaleTimeString()}} + +
+
+
+
+
+
+
+
+ {timelineBlocks} +
+
+
+ + + ); +} diff --git a/web/src/components/Timeline/TimelineBlockView.tsx b/web/src/components/Timeline/TimelineBlockView.tsx new file mode 100644 index 000000000..420266e52 --- /dev/null +++ b/web/src/components/Timeline/TimelineBlockView.tsx @@ -0,0 +1,25 @@ +import { h } from 'preact'; +import { useCallback } from 'preact/hooks'; +import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil'; +import { TimelineEventBlock } from './TimelineEventBlock'; + +interface TimelineBlockViewProps { + block: TimelineEventBlock; + onClick: (block: TimelineEventBlock) => void; +} + +export const TimelineBlockView = ({ block, onClick }: TimelineBlockViewProps) => { + const onClickHandler = useCallback(() => onClick(block), [block, onClick]); + return ( +
+ ); +}; diff --git a/web/src/components/Timeline/TimelineBlocks.tsx b/web/src/components/Timeline/TimelineBlocks.tsx new file mode 100644 index 000000000..2c68c57a3 --- /dev/null +++ b/web/src/components/Timeline/TimelineBlocks.tsx @@ -0,0 +1,47 @@ +import { h } from 'preact'; +import { useMemo } from 'preact/hooks'; +import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils'; +import { convertRemToPixels } from '../../utils/windowUtils'; +import { TimelineBlockView } from './TimelineBlockView'; +import { TimelineEventBlock } from './TimelineEventBlock'; + +interface TimelineBlocksProps { + timeline: TimelineEventBlock[]; + firstBlockOffset: number; + onEventClick: (block: TimelineEventBlock) => void; +} + +export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => { + const timelineEventBlocks = useMemo(() => { + if (timeline.length > 0 && firstBlockOffset) { + const largestYOffsetInBlocks = findLargestYOffsetInBlocks(timeline); + const timelineContainerHeight = largestYOffsetInBlocks + convertRemToPixels(1); + const timelineContainerWidth = getTimelineWidthFromBlocks(timeline, firstBlockOffset); + const timelineBlockOffset = (timelineContainerHeight - largestYOffsetInBlocks) / 2; + return ( +
+ {timeline.map((block) => { + const onClickHandler = (block: TimelineEventBlock) => onEventClick(block); + const updatedBlock: TimelineEventBlock = { + ...block, + yOffset: block.yOffset + timelineBlockOffset, + }; + return ; + })} +
+ ); + } + }, [timeline, onEventClick, firstBlockOffset]); + + return timelineEventBlocks; +}; diff --git a/web/src/components/Timeline/TimelineChangeEvent.ts b/web/src/components/Timeline/TimelineChangeEvent.ts new file mode 100644 index 000000000..d3a457bd4 --- /dev/null +++ b/web/src/components/Timeline/TimelineChangeEvent.ts @@ -0,0 +1,7 @@ +import { TimelineEvent } from './TimelineEvent'; + +export interface TimelineChangeEvent { + timelineEvent: TimelineEvent; + markerTime: Date; + seekComplete: boolean; +} \ No newline at end of file diff --git a/web/src/components/Timeline/TimelineControls.tsx b/web/src/components/Timeline/TimelineControls.tsx new file mode 100644 index 000000000..4e08ec4b7 --- /dev/null +++ b/web/src/components/Timeline/TimelineControls.tsx @@ -0,0 +1,45 @@ +import { h } from 'preact'; +import Next from '../../icons/Next'; +import Pause from '../../icons/Pause'; +import Play from '../../icons/Play'; +import Previous from '../../icons/Previous'; +import { BubbleButton } from '../BubbleButton'; + +export interface DisabledControls { + playPause: boolean; + next: boolean; + previous: boolean; +} + +interface TimelineControlsProps { + disabled: DisabledControls; + className?: string; + isPlaying: boolean; + onPlayPause: (isPlaying: boolean) => void; + onNext: () => void; + onPrevious: () => void; +} + +export const TimelineControls = ({ + disabled, + isPlaying, + onPlayPause, + onNext, + onPrevious, + className = '', +}: TimelineControlsProps) => { + const onPlayClickHandler = () => { + onPlayPause(!isPlaying); + }; + return ( +
+ + + + {!isPlaying ? : } + + + +
+ ); +}; diff --git a/web/src/components/Timeline/TimelineEvent.ts b/web/src/components/Timeline/TimelineEvent.ts new file mode 100644 index 000000000..11bc3e0c1 --- /dev/null +++ b/web/src/components/Timeline/TimelineEvent.ts @@ -0,0 +1,8 @@ +export interface TimelineEvent { + start_time: number; + end_time: number; + startTime: Date; + endTime: Date; + id: string; + label: 'car' | 'person' | 'dog'; +} \ No newline at end of file diff --git a/web/src/components/Timeline/TimelineEventBlock.ts b/web/src/components/Timeline/TimelineEventBlock.ts new file mode 100644 index 000000000..c176c521d --- /dev/null +++ b/web/src/components/Timeline/TimelineEventBlock.ts @@ -0,0 +1,9 @@ +import { TimelineEvent } from './TimelineEvent'; + +export interface TimelineEventBlock extends TimelineEvent { + index: number; + yOffset: number; + width: number; + positionX: number; + seconds: number; +} \ No newline at end of file diff --git a/web/src/components/__tests__/Dialog.test.jsx b/web/src/components/__tests__/Dialog.test.jsx index 646f5a46d..24852e1ff 100644 --- a/web/src/components/__tests__/Dialog.test.jsx +++ b/web/src/components/__tests__/Dialog.test.jsx @@ -1,6 +1,6 @@ import { h } from 'preact'; import Dialog from '../Dialog'; -import { fireEvent, render, screen } from '@testing-library/preact'; +import { render, screen } from '@testing-library/preact'; describe('Dialog', () => { let portal; @@ -16,23 +16,8 @@ describe('Dialog', () => { }); test('renders to a portal', async () => { - render(); - expect(screen.getByText('Tacos')).toBeInTheDocument(); + render(Sample); + expect(screen.getByText('Sample')).toBeInTheDocument(); expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull(); }); - - test('renders action buttons', async () => { - const handleClick = jest.fn(); - render( - - ); - fireEvent.click(screen.getByRole('button', { name: 'Okay' })); - expect(handleClick).toHaveBeenCalled(); - }); }); diff --git a/web/src/components/__tests__/Prompt.test.jsx b/web/src/components/__tests__/Prompt.test.jsx new file mode 100644 index 000000000..ae9f2503e --- /dev/null +++ b/web/src/components/__tests__/Prompt.test.jsx @@ -0,0 +1,38 @@ +import { h } from 'preact'; +import Prompt from '../Prompt'; +import { fireEvent, render, screen } from '@testing-library/preact'; + +describe('Prompt', () => { + let portal; + + beforeAll(() => { + portal = document.createElement('div'); + portal.id = 'dialogs'; + document.body.appendChild(portal); + }); + + afterAll(() => { + document.body.removeChild(portal); + }); + + test('renders to a portal', async () => { + render(); + expect(screen.getByText('Tacos')).toBeInTheDocument(); + expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull(); + }); + + test('renders action buttons', async () => { + const handleClick = jest.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Okay' })); + expect(handleClick).toHaveBeenCalled(); + }); +}); diff --git a/web/src/icons/Next.jsx b/web/src/icons/Next.jsx new file mode 100644 index 000000000..b54e737e6 --- /dev/null +++ b/web/src/icons/Next.jsx @@ -0,0 +1,13 @@ + +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Next({ className = '' }) { + return ( + + + + ); +} + +export default memo(Next); diff --git a/web/src/icons/Pause.jsx b/web/src/icons/Pause.jsx new file mode 100644 index 000000000..3b6a143d6 --- /dev/null +++ b/web/src/icons/Pause.jsx @@ -0,0 +1,13 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Pause({ className = '' }) { + return ( + + + + + ); +} + +export default memo(Pause); diff --git a/web/src/icons/Play.jsx b/web/src/icons/Play.jsx new file mode 100644 index 000000000..d0c401b22 --- /dev/null +++ b/web/src/icons/Play.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Play({ className = '' }) { + return ( + + + + ); +} + +export default memo(Play); diff --git a/web/src/icons/Previous.jsx b/web/src/icons/Previous.jsx new file mode 100644 index 000000000..fbc8ed846 --- /dev/null +++ b/web/src/icons/Previous.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Previous({ className = '' }) { + return ( + + + + ); +} + +export default memo(Previous); diff --git a/web/src/index.css b/web/src/index.css index b7b93a69e..dfb1f165e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -39,6 +39,15 @@ Could not find a proper tailwind css. max-width: 70%; } +.hide-scroll::-webkit-scrollbar { + display: none; +} + +.hide-scroll { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + /* Hide some videoplayer controls on mobile devices to align the video player and bottom control bar properly. diff --git a/web/src/routes/Birdseye.jsx b/web/src/routes/Birdseye.jsx index 5222854e9..37f4d0ef0 100644 --- a/web/src/routes/Birdseye.jsx +++ b/web/src/routes/Birdseye.jsx @@ -4,7 +4,7 @@ import Heading from '../components/Heading'; export default function Birdseye() { return ( -
+
Birdseye
diff --git a/web/src/routes/Camera.jsx b/web/src/routes/Camera.jsx index 503bf55d5..64d652a6c 100644 --- a/web/src/routes/Camera.jsx +++ b/web/src/routes/Camera.jsx @@ -112,7 +112,7 @@ export default function Camera({ camera }) { } return ( -
+
{camera} diff --git a/web/src/routes/CameraMap.jsx b/web/src/routes/CameraMap.jsx index d6837ba8f..8235b77b4 100644 --- a/web/src/routes/CameraMap.jsx +++ b/web/src/routes/CameraMap.jsx @@ -199,7 +199,7 @@ ${Object.keys(objectMaskPoints) ); return ( -
+
{camera} mask & zone creator { + if (index === 0) { + setPlayerType('history'); + } else if (index === 1) { + setPlayerType('live'); + } else if (index === 2) { + setPlayerType('debug'); + } + }; + + return ( +
+
+
+
+ {(playerType === 'live' || playerType === 'debug') && ( + + + {camera} + + + + )} +
+
+ +
+ +
+ +
+ + + + + +
+
+
+ ); +} + +const RenderPlayer = function ({ camera, cameraConfig, playerType }) { + const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height)); + if (playerType === 'live') { + return ( + +
+ +
+
+ ); + } else if (playerType === 'history') { + return ; + } else if (playerType === 'debug') { + return ; + } + return ; +}; diff --git a/web/src/routes/Cameras.jsx b/web/src/routes/Cameras.jsx index 3bb5647bd..89ff9c448 100644 --- a/web/src/routes/Cameras.jsx +++ b/web/src/routes/Cameras.jsx @@ -15,7 +15,7 @@ export default function Cameras() { return status !== FetchStatus.LOADED ? ( ) : ( -
+
{Object.entries(config.cameras).map(([camera, conf]) => ( ))} diff --git a/web/src/routes/Debug.jsx b/web/src/routes/Debug.jsx index 6e04509bd..f9b35c1a8 100644 --- a/web/src/routes/Debug.jsx +++ b/web/src/routes/Debug.jsx @@ -33,7 +33,7 @@ export default function Debug() { }, [config]); return ( -
+
Debug {service.version} diff --git a/web/src/routes/Event.jsx b/web/src/routes/Event.jsx index 2b58bc698..339820bc2 100644 --- a/web/src/routes/Event.jsx +++ b/web/src/routes/Event.jsx @@ -10,11 +10,11 @@ import Close from '../icons/Close'; import StarRecording from '../icons/StarRecording'; import Delete from '../icons/Delete'; import Snapshot from '../icons/Snapshot'; -import Dialog from '../components/Dialog'; import Heading from '../components/Heading'; import VideoPlayer from '../components/VideoPlayer'; import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table'; import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api'; +import Prompt from '../components/Prompt'; const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
@@ -145,7 +145,7 @@ export default function Event({ eventId, close, scrollRef }) {
{showDialog ? ( - +
Events
diff --git a/web/src/routes/Recording.jsx b/web/src/routes/Recording.jsx index 888872437..de17cf635 100644 --- a/web/src/routes/Recording.jsx +++ b/web/src/routes/Recording.jsx @@ -72,7 +72,7 @@ export default function Recording({ camera, date, hour, seconds }) { } return ( -
+
{camera} Recordings +
Button
diff --git a/web/src/routes/index.js b/web/src/routes/index.js index d9b776d87..ef8cfaf9d 100644 --- a/web/src/routes/index.js +++ b/web/src/routes/index.js @@ -8,6 +8,11 @@ export async function getCamera(url, cb, props) { return module.default; } +export async function getCameraV2(url, cb, props) { + const module = await import('./Camera_V2.jsx'); + return module.default; +} + export async function getEvent(url, cb, props) { const module = await import('./Event.jsx'); return module.default; diff --git a/web/src/utils/Timeline/timelineEventUtils.ts b/web/src/utils/Timeline/timelineEventUtils.ts new file mode 100644 index 000000000..b733a6f93 --- /dev/null +++ b/web/src/utils/Timeline/timelineEventUtils.ts @@ -0,0 +1,73 @@ +import { TimelineEvent } from '../../components/Timeline/TimelineEvent'; +import { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock'; +import { epochToLong, longToDate } from '../dateUtil'; + +export const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => { + if (secondEvent.startTime < firstEvent.endTime && secondEvent.startTime > firstEvent.startTime) { + return true; + } + return false; +}; + +export const getTimelineEventBlocksFromTimelineEvents = (events: TimelineEvent[], xOffset: number): TimelineEventBlock[] => { + const firstEvent = events[0]; + const firstEventTime = longToDate(firstEvent.start_time); + return events + .map((e, index) => { + const startTime = longToDate(e.start_time); + const endTime = e.end_time ? longToDate(e.end_time) : new Date(); + const seconds = Math.round(Math.abs(endTime.getTime() - startTime.getTime()) / 1000); + const positionX = Math.round(Math.abs(startTime.getTime() - firstEventTime.getTime()) / 1000 + xOffset); + return { + ...e, + startTime, + endTime, + width: seconds, + positionX, + index, + } as TimelineEventBlock; + }) + .reduce((rowMap, current) => { + for (let i = 0; i < rowMap.length; i++) { + const row = rowMap[i] ?? []; + const lastItem = row[row.length - 1]; + if (lastItem) { + const isOverlap = checkEventForOverlap(lastItem, current); + if (isOverlap) { + continue; + } + } + rowMap[i] = [...row, current]; + return rowMap; + } + rowMap.push([current]); + return rowMap; + }, [] as TimelineEventBlock[][]) + .flatMap((rows, rowPosition) => { + rows.forEach((eventBlock) => { + const OFFSET_DISTANCE_IN_PIXELS = 10; + eventBlock.yOffset = OFFSET_DISTANCE_IN_PIXELS * rowPosition; + }); + return rows; + }) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); +} + +export const findLargestYOffsetInBlocks = (blocks: TimelineEventBlock[]): number => { + return blocks.reduce((largestYOffset, current) => { + if (current.yOffset > largestYOffset) { + return current.yOffset + } + return largestYOffset; + }, 0) +}; + +export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset: number): number => { + const firstBlock = blocks[0]; + if (firstBlock) { + const startTimeEpoch = firstBlock.startTime.getTime(); + const endTimeEpoch = Date.now(); + const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch); + return timelineDurationLong + offset * 2 + } +} diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts new file mode 100644 index 000000000..51c9bcaa3 --- /dev/null +++ b/web/src/utils/dateUtil.ts @@ -0,0 +1,16 @@ +export const longToDate = (long: number): Date => new Date(long * 1000); +export const epochToLong = (date: number): number => date / 1000; +export const dateToLong = (date: Date): number => epochToLong(date.getTime()); + +const getDateTimeYesterday = (dateTime: Date): Date => { + const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000; + return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds); +} + +const getNowYesterday = (): Date => { + return getDateTimeYesterday(new Date()); +} + +export const getNowYesterdayInLong = (): number => { + return dateToLong(getNowYesterday()); +}; diff --git a/web/src/utils/objectUtils.ts b/web/src/utils/objectUtils.ts new file mode 100644 index 000000000..01aab7839 --- /dev/null +++ b/web/src/utils/objectUtils.ts @@ -0,0 +1 @@ +export const isNullOrUndefined = (object?: unknown): boolean => object === null || object === undefined; \ No newline at end of file diff --git a/web/src/utils/tailwind/twTimelineEventUtil.ts b/web/src/utils/tailwind/twTimelineEventUtil.ts new file mode 100644 index 000000000..82d6dffbf --- /dev/null +++ b/web/src/utils/tailwind/twTimelineEventUtil.ts @@ -0,0 +1,15 @@ +import { TimelineEvent } from '../../components/Timeline/TimelineEvent'; + +export const getColorFromTimelineEvent = (event: TimelineEvent) => { + const { label } = event; + if (label === 'car') { + return 'bg-red-400'; + } else if (label === 'person') { + return 'bg-blue-400'; + } else if (label === 'dog') { + return 'bg-green-400'; + } + + // unknown label + return 'bg-gray-400'; +}; \ No newline at end of file diff --git a/web/src/utils/windowUtils.ts b/web/src/utils/windowUtils.ts new file mode 100644 index 000000000..fc12b0096 --- /dev/null +++ b/web/src/utils/windowUtils.ts @@ -0,0 +1,3 @@ +export const convertRemToPixels = (rem: number): number => { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 1a1775327..774af9f06 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,5 +1,5 @@ module.exports = { - purge: ['./public/**/*.html', './src/**/*.jsx'], + purge: ['./public/**/*.html', './src/**/*.{jsx,tsx}', './src/utils/tailwind/*.{jsx,tsx,js,ts}'], darkMode: 'class', theme: { extend: { diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..5c5356777 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2019", + "jsx": "react", + "jsxFactory": "h", + "lib": [ + "ES2019" + ] + }, + "include": [ + "./src/**/*.tsx", + "./src/**/*.ts" + ] +}