mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
feat: Timeline UI (#2830)
This commit is contained in:
parent
4004048add
commit
3e07d4eddb
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,3 +14,4 @@ web/build
|
||||
web/node_modules
|
||||
web/coverage
|
||||
core
|
||||
!/web/**/*.ts
|
||||
|
15
docs/docs/configuration/user_interface.md
Normal file
15
docs/docs/configuration/user_interface.md
Normal file
@ -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.
|
@ -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."
|
||||
)
|
||||
|
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -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' }]],
|
||||
};
|
||||
|
8
web/jsconfig.json
Normal file
8
web/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
}
|
||||
}
|
749
web/package-lock.json
generated
749
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true,
|
||||
useTabs: false,
|
||||
};
|
||||
|
BIN
web/public/marker.png
Normal file
BIN
web/public/marker.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 534 B |
@ -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 (
|
||||
<DarkModeProvider>
|
||||
<DrawerProvider>
|
||||
@ -23,10 +24,10 @@ export default function App() {
|
||||
) : (
|
||||
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<Sidebar />
|
||||
<div className="w-full flex-auto p-2 mt-16 px-4 min-w-0">
|
||||
<div className="w-full flex-auto mt-16 min-w-0">
|
||||
<Router>
|
||||
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
||||
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
||||
<AsyncRoute path="/cameras/:camera" getComponent={cameraComponent} />
|
||||
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
||||
|
@ -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 (
|
||||
<div>
|
||||
<div className={className}>
|
||||
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||
</div>
|
||||
|
45
web/src/components/BubbleButton.tsx
Normal file
45
web/src/components/BubbleButton.tsx
Normal file
@ -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 (
|
||||
<button onClick={onClickHandler} className={`${computedClass} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
74
web/src/components/DebugCamera.jsx
Normal file
74
web/src/components/DebugCamera.jsx
Normal file
@ -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 = (
|
||||
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
|
||||
<Switch
|
||||
checked={options['bbox']}
|
||||
id='bbox'
|
||||
onChange={handleSetOption}
|
||||
label='Bounding box'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch
|
||||
checked={options['timestamp']}
|
||||
id='timestamp'
|
||||
onChange={handleSetOption}
|
||||
label='Timestamp'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch checked={options['zones']} id='zones' onChange={handleSetOption} label='Zones' labelPosition='after' />
|
||||
<Switch checked={options['mask']} id='mask' onChange={handleSetOption} label='Masks' labelPosition='after' />
|
||||
<Switch
|
||||
checked={options['motion']}
|
||||
id='motion'
|
||||
onChange={handleSetOption}
|
||||
label='Motion boxes'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch
|
||||
checked={options['regions']}
|
||||
id='regions'
|
||||
onChange={handleSetOption}
|
||||
label='Regions'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
|
||||
{optionContent}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Heading size="lg">{title}</Heading>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||
<Button className="ml-2" color={color} key={i} onClick={onClick} type="text" {...props}>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
30
web/src/components/HistoryViewer/HistoryHeader.tsx
Normal file
30
web/src/components/HistoryViewer/HistoryHeader.tsx
Normal file
@ -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 = <span>Event was not found at marker position.</span>;
|
||||
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 = (
|
||||
<span>
|
||||
{isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} ·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<div>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
134
web/src/components/HistoryViewer/HistoryVideo.tsx
Normal file
134
web/src/components/HistoryViewer/HistoryVideo.tsx
Normal file
@ -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<HTMLVideoElement>();
|
||||
const [videoHeight, setVideoHeight] = useState<number>(undefined);
|
||||
const [videoProperties, setVideoProperties] = useState<VideoProperties>(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 <div style={{ height: `${videoHeight}px`, width: '100%' }} />;
|
||||
}
|
||||
|
||||
const { posterUrl, videoUrl, height } = videoProperties;
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={posterUrl}
|
||||
onTimeUpdate={onTimeUpdateHandler}
|
||||
onPause={onPause}
|
||||
onPlay={onPlay}
|
||||
poster={posterUrl}
|
||||
preload='metadata'
|
||||
controls
|
||||
style={height ? { minHeight: `${height}px` } : {}}
|
||||
playsInline
|
||||
>
|
||||
<source type='application/vnd.apple.mpegurl' src={videoUrl} />
|
||||
</video>
|
||||
);
|
||||
};
|
79
web/src/components/HistoryViewer/HistoryViewer.tsx
Normal file
79
web/src/components/HistoryViewer/HistoryViewer.tsx
Normal file
@ -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<TimelineEvent[]>(undefined);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
|
||||
const [isPlaying, setIsPlaying] = useState(undefined);
|
||||
const [currentTime, setCurrentTime] = useState<number>(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 (
|
||||
<Fragment>
|
||||
<Fragment>
|
||||
<div className='relative flex flex-col'>
|
||||
<Fragment>
|
||||
<HistoryHeader event={currentEvent} className='mb-2' />
|
||||
<HistoryVideo
|
||||
id={currentEvent ? currentEvent.id : undefined}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
onPlay={onPlayHandler}
|
||||
onPause={onPausedHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<Timeline
|
||||
events={timelineEvents}
|
||||
isPlaying={isPlaying}
|
||||
onChange={handleTimelineChange}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
18
web/src/components/LiveChip.jsx
Normal file
18
web/src/components/LiveChip.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export function LiveChip({ className }) {
|
||||
return (
|
||||
<div className={`inline relative px-2 py-1 rounded-full ${className}`}>
|
||||
<div className='relative inline-block w-3 h-3 mr-2'>
|
||||
<span class='flex h-3 w-3'>
|
||||
<span
|
||||
class='animate-ping absolute inline-flex h-full w-full rounded-full opacity-75'
|
||||
style={{ backgroundColor: 'rgb(74 222 128)' }}
|
||||
/>
|
||||
<span class='relative inline-flex rounded-full h-3 w-3' style={{ backgroundColor: 'rgb(74 222 128)' }} />
|
||||
</span>
|
||||
</div>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
22
web/src/components/Prompt.jsx
Normal file
22
web/src/components/Prompt.jsx
Normal file
@ -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 (
|
||||
<Dialog>
|
||||
<div className='p-4'>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
<div className='p-2 flex justify-start flex-row-reverse space-x-2'>
|
||||
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||
<Button className='ml-2' color={color} key={i} onClick={onClick} type='text' {...props}>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
39
web/src/components/Tabs.jsx
Normal file
39
web/src/components/Tabs.jsx
Normal file
@ -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 (
|
||||
<div className={`flex ${className}`}>
|
||||
<RenderChildren />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button onClick={onClick} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
4
web/src/components/Timeline/ScrollPermission.ts
Normal file
4
web/src/components/Timeline/ScrollPermission.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface ScrollPermission {
|
||||
allowed: boolean;
|
||||
resetAfterSeeked: boolean;
|
||||
}
|
242
web/src/components/Timeline/Timeline.tsx
Normal file
242
web/src/components/Timeline/Timeline.tsx
Normal file
@ -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<HTMLDivElement>(undefined);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
|
||||
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
|
||||
playPause: false,
|
||||
next: true,
|
||||
previous: false,
|
||||
});
|
||||
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
|
||||
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
|
||||
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 <TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />;
|
||||
}
|
||||
}, [timeline, timelineOffset, handleViewEvent]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='flex-grow-1'>
|
||||
<div className='w-full text-center'>
|
||||
<span className='text-black dark:text-white'>
|
||||
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='absolute left-0 top-0 h-full w-full text-center'>
|
||||
<div className='h-full text-center' style={{ margin: '0 auto' }}>
|
||||
<div
|
||||
className='z-20 h-full absolute'
|
||||
style={{
|
||||
left: 'calc(100% / 2)',
|
||||
borderRight: '2px solid rgba(252, 211, 77)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
{timelineBlocks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineControls
|
||||
disabled={disabledControls}
|
||||
isPlaying={isPlaying}
|
||||
onPrevious={onPreviousHandler}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
onNext={onNextHandler}
|
||||
className='mt-2'
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
25
web/src/components/Timeline/TimelineBlockView.tsx
Normal file
25
web/src/components/Timeline/TimelineBlockView.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
key={block.id}
|
||||
onClick={onClickHandler}
|
||||
className={`absolute z-10 rounded-full ${getColorFromTimelineEvent(block)} h-2`}
|
||||
style={{
|
||||
top: `${block.yOffset}px`,
|
||||
left: `${block.positionX}px`,
|
||||
width: `${block.width}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
47
web/src/components/Timeline/TimelineBlocks.tsx
Normal file
47
web/src/components/Timeline/TimelineBlocks.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className='relative'
|
||||
style={{
|
||||
height: `${timelineContainerHeight}px`,
|
||||
width: `${timelineContainerWidth}px`,
|
||||
background: "url('/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '30px',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
{timeline.map((block) => {
|
||||
const onClickHandler = (block: TimelineEventBlock) => onEventClick(block);
|
||||
const updatedBlock: TimelineEventBlock = {
|
||||
...block,
|
||||
yOffset: block.yOffset + timelineBlockOffset,
|
||||
};
|
||||
return <TimelineBlockView block={updatedBlock} onClick={onClickHandler} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [timeline, onEventClick, firstBlockOffset]);
|
||||
|
||||
return timelineEventBlocks;
|
||||
};
|
7
web/src/components/Timeline/TimelineChangeEvent.ts
Normal file
7
web/src/components/Timeline/TimelineChangeEvent.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
45
web/src/components/Timeline/TimelineControls.tsx
Normal file
45
web/src/components/Timeline/TimelineControls.tsx
Normal file
@ -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 (
|
||||
<div className={`flex space-x-2 self-center ${className}`}>
|
||||
<BubbleButton variant='secondary' onClick={onPrevious} disabled={disabled.previous}>
|
||||
<Previous />
|
||||
</BubbleButton>
|
||||
<BubbleButton onClick={onPlayClickHandler}>{!isPlaying ? <Play /> : <Pause />}</BubbleButton>
|
||||
<BubbleButton variant='secondary' onClick={onNext} disabled={disabled.next}>
|
||||
<Next />
|
||||
</BubbleButton>
|
||||
</div>
|
||||
);
|
||||
};
|
8
web/src/components/Timeline/TimelineEvent.ts
Normal file
8
web/src/components/Timeline/TimelineEvent.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
id: string;
|
||||
label: 'car' | 'person' | 'dog';
|
||||
}
|
9
web/src/components/Timeline/TimelineEventBlock.ts
Normal file
9
web/src/components/Timeline/TimelineEventBlock.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
positionX: number;
|
||||
seconds: number;
|
||||
}
|
@ -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(<Dialog title="Tacos" text="This is the dialog" />);
|
||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||
render(<Dialog>Sample</Dialog>);
|
||||
expect(screen.getByText('Sample')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders action buttons', async () => {
|
||||
const handleClick = jest.fn();
|
||||
render(
|
||||
<Dialog
|
||||
actions={[
|
||||
{ color: 'red', text: 'Delete' },
|
||||
{ text: 'Okay', onClick: handleClick },
|
||||
]}
|
||||
title="Tacos"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
38
web/src/components/__tests__/Prompt.test.jsx
Normal file
38
web/src/components/__tests__/Prompt.test.jsx
Normal file
@ -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(<Prompt title='Tacos' text='This is the dialog' />);
|
||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders action buttons', async () => {
|
||||
const handleClick = jest.fn();
|
||||
render(
|
||||
<Prompt
|
||||
actions={[
|
||||
{ color: 'red', text: 'Delete' },
|
||||
{ text: 'Okay', onClick: handleClick },
|
||||
]}
|
||||
title='Tacos'
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
13
web/src/icons/Next.jsx
Normal file
13
web/src/icons/Next.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Next({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path d="M4,5V19L11,12M18,5V19H20V5M11,5V19L18,12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Next);
|
13
web/src/icons/Pause.jsx
Normal file
13
web/src/icons/Pause.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Pause({ className = '' }) {
|
||||
return (
|
||||
<svg height='24' viewBox='0 0 24 24' width='24' className={className}>
|
||||
<path d='M0 0h24v24H0V0z' fill='none' />
|
||||
<path d='M6 19h4V5H6v14zm8-14v14h4V5h-4z' className='fill-current' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Pause);
|
12
web/src/icons/Play.jsx
Normal file
12
web/src/icons/Play.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Play({ className = '' }) {
|
||||
return (
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Play);
|
12
web/src/icons/Previous.jsx
Normal file
12
web/src/icons/Previous.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Previous({ className = '' }) {
|
||||
return (
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20,5V19L13,12M6,5V19H4V5M13,5V19L6,12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Previous);
|
@ -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.
|
||||
|
@ -4,7 +4,7 @@ import Heading from '../components/Heading';
|
||||
|
||||
export default function Birdseye() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-2 px-4">
|
||||
<Heading size="2xl">Birdseye</Heading>
|
||||
<div>
|
||||
<JSMpegPlayer camera="birdseye" />
|
||||
|
@ -112,7 +112,7 @@ export default function Camera({ camera }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-2 px-4">
|
||||
<Heading size="2xl">{camera}</Heading>
|
||||
<ButtonsTabbed viewModes={['live', 'debug']} setViewMode={setViewMode} />
|
||||
|
||||
|
@ -199,7 +199,7 @@ ${Object.keys(objectMaskPoints)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-col space-y-4">
|
||||
<div className="flex-col space-y-4 p-2 px-4">
|
||||
<Heading size="2xl">{camera} mask & zone creator</Heading>
|
||||
|
||||
<Card
|
||||
|
76
web/src/routes/Camera_V2.jsx
Normal file
76
web/src/routes/Camera_V2.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import JSMpegPlayer from '../components/JSMpegPlayer';
|
||||
import Heading from '../components/Heading';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useConfig } from '../api';
|
||||
import { Tabs, TextTab } from '../components/Tabs';
|
||||
import { LiveChip } from '../components/LiveChip';
|
||||
import { DebugCamera } from '../components/DebugCamera';
|
||||
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
|
||||
|
||||
export default function Camera({ camera }) {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const [playerType, setPlayerType] = useState('live');
|
||||
|
||||
const cameraConfig = config?.cameras[camera];
|
||||
|
||||
const handleTabChange = (index) => {
|
||||
if (index === 0) {
|
||||
setPlayerType('history');
|
||||
} else if (index === 1) {
|
||||
setPlayerType('live');
|
||||
} else if (index === 2) {
|
||||
setPlayerType('debug');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex bg-white dark:bg-black w-full h-full justify-center'>
|
||||
<div className='relative max-w-screen-md flex-grow w-full'>
|
||||
<div className='absolute top-0 text-white w-full'>
|
||||
<div className='flex pt-4 pl-4 items-center w-full h-16 z10'>
|
||||
{(playerType === 'live' || playerType === 'debug') && (
|
||||
<Fragment>
|
||||
<Heading size='xl' className='mr-2 text-black dark:text-white'>
|
||||
{camera}
|
||||
</Heading>
|
||||
<LiveChip className='text-green-400 border-2 border-solid border-green-400 bg-opacity-40 dark:bg-opacity-10' />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col justify-center h-full'>
|
||||
<RenderPlayer camera={camera} cameraConfig={cameraConfig} playerType={playerType} />
|
||||
</div>
|
||||
|
||||
<div className='absolute flex justify-center bottom-8 w-full'>
|
||||
<Tabs selectedIndex={1} onChange={handleTabChange} className='justify'>
|
||||
<TextTab text='History' />
|
||||
<TextTab text='Live' />
|
||||
<TextTab text='Debug' />
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RenderPlayer = function ({ camera, cameraConfig, playerType }) {
|
||||
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
|
||||
if (playerType === 'live') {
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
} else if (playerType === 'history') {
|
||||
return <HistoryViewer camera={camera} />;
|
||||
} else if (playerType === 'debug') {
|
||||
return <DebugCamera camera={camera} />;
|
||||
}
|
||||
return <Fragment />;
|
||||
};
|
@ -15,7 +15,7 @@ export default function Cameras() {
|
||||
return status !== FetchStatus.LOADED ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
|
||||
{Object.entries(config.cameras).map(([camera, conf]) => (
|
||||
<Camera name={camera} conf={conf} />
|
||||
))}
|
||||
|
@ -33,7 +33,7 @@ export default function Debug() {
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-2 px-4">
|
||||
<Heading>
|
||||
Debug <span className="text-sm">{service.version}</span>
|
||||
</Heading>
|
||||
|
@ -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 }) => (
|
||||
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
|
||||
@ -145,7 +145,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
</div>
|
||||
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
||||
{showDialog ? (
|
||||
<Dialog
|
||||
<Prompt
|
||||
onDismiss={handleDismissDeleteDialog}
|
||||
title="Delete Event?"
|
||||
text={
|
||||
|
@ -81,7 +81,7 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
||||
[apiHost, handleFilter, pathname, scrollToRef]
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="space-y-4 p-2 px-4 w-full">
|
||||
<Heading>Events</Heading>
|
||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||
<div className="min-w-0 overflow-auto">
|
||||
|
@ -72,7 +72,7 @@ export default function Recording({ camera, date, hour, seconds }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-2 px-4">
|
||||
<Heading>{camera} Recordings</Heading>
|
||||
|
||||
<VideoPlayer
|
||||
|
@ -25,7 +25,7 @@ export default function StyleGuide() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-2 px-4">
|
||||
<Heading size="md">Button</Heading>
|
||||
<div className="flex space-x-4 mb-4">
|
||||
<Button>Default</Button>
|
||||
|
@ -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;
|
||||
|
73
web/src/utils/Timeline/timelineEventUtils.ts
Normal file
73
web/src/utils/Timeline/timelineEventUtils.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
16
web/src/utils/dateUtil.ts
Normal file
16
web/src/utils/dateUtil.ts
Normal file
@ -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());
|
||||
};
|
1
web/src/utils/objectUtils.ts
Normal file
1
web/src/utils/objectUtils.ts
Normal file
@ -0,0 +1 @@
|
||||
export const isNullOrUndefined = (object?: unknown): boolean => object === null || object === undefined;
|
15
web/src/utils/tailwind/twTimelineEventUtil.ts
Normal file
15
web/src/utils/tailwind/twTimelineEventUtil.ts
Normal file
@ -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';
|
||||
};
|
3
web/src/utils/windowUtils.ts
Normal file
3
web/src/utils/windowUtils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const convertRemToPixels = (rem: number): number => {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
@ -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: {
|
||||
|
15
web/tsconfig.json
Normal file
15
web/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES2019",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h",
|
||||
"lib": [
|
||||
"ES2019"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.tsx",
|
||||
"./src/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user