mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +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/node_modules
|
||||||
web/coverage
|
web/coverage
|
||||||
core
|
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")
|
device: str = Field(default="usb", title="Device Type")
|
||||||
num_threads: int = Field(default=3, title="Number of detection threads")
|
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):
|
class MqttConfig(FrigateBaseModel):
|
||||||
host: str = Field(title="MQTT Host")
|
host: str = Field(title="MQTT Host")
|
||||||
@ -709,6 +711,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
environment_vars: Dict[str, str] = Field(
|
environment_vars: Dict[str, str] = Field(
|
||||||
default_factory=dict, title="Frigate environment variables."
|
default_factory=dict, title="Frigate environment variables."
|
||||||
)
|
)
|
||||||
|
ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.")
|
||||||
model: ModelConfig = Field(
|
model: ModelConfig = Field(
|
||||||
default_factory=ModelConfig, title="Detection model configuration."
|
default_factory=ModelConfig, title="Detection model configuration."
|
||||||
)
|
)
|
||||||
|
@ -13,6 +13,7 @@ module.exports = {
|
|||||||
'prettier',
|
'prettier',
|
||||||
'preact',
|
'preact',
|
||||||
'plugin:import/react',
|
'plugin:import/react',
|
||||||
|
'plugin:import/typescript',
|
||||||
'plugin:testing-library/recommended',
|
'plugin:testing-library/recommended',
|
||||||
'plugin:jest/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 = {
|
module.exports = {
|
||||||
presets: ['@babel/preset-env'],
|
presets: ['@babel/preset-env', ['@babel/typescript', { jsxPragma: 'h' }]],
|
||||||
plugins: [['@babel/plugin-transform-react-jsx', { pragma: '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"
|
"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": {
|
"@babel/helper-explode-assignable-expression": {
|
||||||
"version": "7.12.13",
|
"version": "7.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.13.tgz",
|
"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": {
|
"@babel/plugin-transform-arrow-functions": {
|
||||||
"version": "7.12.13",
|
"version": "7.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.13.tgz",
|
"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": {
|
"@babel/plugin-transform-unicode-escapes": {
|
||||||
"version": "7.12.13",
|
"version": "7.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz",
|
"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"
|
"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": {
|
"@babel/runtime": {
|
||||||
"version": "7.12.13",
|
"version": "7.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz",
|
||||||
@ -18823,6 +19092,102 @@
|
|||||||
"integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==",
|
"integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==",
|
||||||
"dev": true
|
"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": {
|
"@typescript-eslint/experimental-utils": {
|
||||||
"version": "4.15.0",
|
"version": "4.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.0.tgz",
|
||||||
@ -18837,6 +19202,164 @@
|
|||||||
"eslint-utils": "^2.0.0"
|
"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": {
|
"@typescript-eslint/scope-manager": {
|
||||||
"version": "4.15.0",
|
"version": "4.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.15.0.tgz",
|
"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/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": {
|
"@typescript-eslint/types": {
|
||||||
"version": "4.15.0",
|
"version": "4.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.15.0.tgz",
|
||||||
@ -18868,6 +19434,189 @@
|
|||||||
"tsutils": "^3.17.1"
|
"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": {
|
"@typescript-eslint/visitor-keys": {
|
||||||
"version": "4.15.0",
|
"version": "4.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.0.tgz",
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"prebuild": "rimraf build",
|
"prebuild": "rimraf build",
|
||||||
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
|
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
|
||||||
"lint": "npm run lint:cmd -- --fix",
|
"lint": "npm run lint:cmd -- --fix",
|
||||||
"lint:cmd": "eslint ./ --ext .jsx,.js",
|
"lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -26,11 +26,14 @@
|
|||||||
"@babel/eslint-parser": "^7.12.13",
|
"@babel/eslint-parser": "^7.12.13",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||||
"@babel/preset-env": "^7.12.13",
|
"@babel/preset-env": "^7.12.13",
|
||||||
|
"@babel/preset-typescript": "^7.16.7",
|
||||||
"@prefresh/snowpack": "^3.0.1",
|
"@prefresh/snowpack": "^3.0.1",
|
||||||
"@snowpack/plugin-postcss": "^1.1.0",
|
"@snowpack/plugin-postcss": "^1.1.0",
|
||||||
"@testing-library/jest-dom": "^5.11.9",
|
"@testing-library/jest-dom": "^5.11.9",
|
||||||
"@testing-library/preact": "^2.0.1",
|
"@testing-library/preact": "^2.0.1",
|
||||||
"@testing-library/user-event": "^12.7.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",
|
"autoprefixer": "^10.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^7.19.0",
|
"eslint": "^7.19.0",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
printWidth: 120,
|
printWidth: 120,
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
|
jsxSingleQuote: true,
|
||||||
useTabs: false,
|
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';
|
import { FetchStatus, useConfig } from './api';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { status } = useConfig();
|
const { status, data: config } = useConfig();
|
||||||
|
const cameraComponent = config && config.ui.use_experimental ? Routes.getCameraV2 : Routes.getCamera;
|
||||||
return (
|
return (
|
||||||
<DarkModeProvider>
|
<DarkModeProvider>
|
||||||
<DrawerProvider>
|
<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">
|
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
<Sidebar />
|
<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>
|
<Router>
|
||||||
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
<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="/birdseye" getComponent={Routes.getBirdseye} />
|
||||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||||
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
<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;
|
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 [key, setKey] = useState(Date.now());
|
||||||
const [fps, setFps] = useState(0);
|
const [fps, setFps] = useState(0);
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams = '', sho
|
|||||||
}, [key, setFps]);
|
}, [key, setFps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
||||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||||
</div>
|
</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 { h, Fragment } from 'preact';
|
||||||
import Button from './Button';
|
|
||||||
import Heading from './Heading';
|
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
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 portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
@ -27,17 +25,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
|
|||||||
show ? 'scale-100 opacity-100' : ''
|
show ? 'scale-100 opacity-100' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
{children}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</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 { h } from 'preact';
|
||||||
import Dialog from '../Dialog';
|
import Dialog from '../Dialog';
|
||||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
import { render, screen } from '@testing-library/preact';
|
||||||
|
|
||||||
describe('Dialog', () => {
|
describe('Dialog', () => {
|
||||||
let portal;
|
let portal;
|
||||||
@ -16,23 +16,8 @@ describe('Dialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('renders to a portal', async () => {
|
test('renders to a portal', async () => {
|
||||||
render(<Dialog title="Tacos" text="This is the dialog" />);
|
render(<Dialog>Sample</Dialog>);
|
||||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
expect(screen.getByText('Sample')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
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%;
|
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
|
Hide some videoplayer controls on mobile devices to
|
||||||
align the video player and bottom control bar properly.
|
align the video player and bottom control bar properly.
|
||||||
|
@ -4,7 +4,7 @@ import Heading from '../components/Heading';
|
|||||||
|
|
||||||
export default function Birdseye() {
|
export default function Birdseye() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-2 px-4">
|
||||||
<Heading size="2xl">Birdseye</Heading>
|
<Heading size="2xl">Birdseye</Heading>
|
||||||
<div>
|
<div>
|
||||||
<JSMpegPlayer camera="birdseye" />
|
<JSMpegPlayer camera="birdseye" />
|
||||||
|
@ -112,7 +112,7 @@ export default function Camera({ camera }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-2 px-4">
|
||||||
<Heading size="2xl">{camera}</Heading>
|
<Heading size="2xl">{camera}</Heading>
|
||||||
<ButtonsTabbed viewModes={['live', 'debug']} setViewMode={setViewMode} />
|
<ButtonsTabbed viewModes={['live', 'debug']} setViewMode={setViewMode} />
|
||||||
|
|
||||||
|
@ -199,7 +199,7 @@ ${Object.keys(objectMaskPoints)
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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>
|
<Heading size="2xl">{camera} mask & zone creator</Heading>
|
||||||
|
|
||||||
<Card
|
<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 ? (
|
return status !== FetchStatus.LOADED ? (
|
||||||
<ActivityIndicator />
|
<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]) => (
|
{Object.entries(config.cameras).map(([camera, conf]) => (
|
||||||
<Camera name={camera} conf={conf} />
|
<Camera name={camera} conf={conf} />
|
||||||
))}
|
))}
|
||||||
|
@ -33,7 +33,7 @@ export default function Debug() {
|
|||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-2 px-4">
|
||||||
<Heading>
|
<Heading>
|
||||||
Debug <span className="text-sm">{service.version}</span>
|
Debug <span className="text-sm">{service.version}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
|
@ -10,11 +10,11 @@ import Close from '../icons/Close';
|
|||||||
import StarRecording from '../icons/StarRecording';
|
import StarRecording from '../icons/StarRecording';
|
||||||
import Delete from '../icons/Delete';
|
import Delete from '../icons/Delete';
|
||||||
import Snapshot from '../icons/Snapshot';
|
import Snapshot from '../icons/Snapshot';
|
||||||
import Dialog from '../components/Dialog';
|
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import VideoPlayer from '../components/VideoPlayer';
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
||||||
import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api';
|
import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api';
|
||||||
|
import Prompt from '../components/Prompt';
|
||||||
|
|
||||||
const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
|
const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
|
||||||
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
|
<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>
|
</div>
|
||||||
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
||||||
{showDialog ? (
|
{showDialog ? (
|
||||||
<Dialog
|
<Prompt
|
||||||
onDismiss={handleDismissDeleteDialog}
|
onDismiss={handleDismissDeleteDialog}
|
||||||
title="Delete Event?"
|
title="Delete Event?"
|
||||||
text={
|
text={
|
||||||
|
@ -81,7 +81,7 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|||||||
[apiHost, handleFilter, pathname, scrollToRef]
|
[apiHost, handleFilter, pathname, scrollToRef]
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 w-full">
|
<div className="space-y-4 p-2 px-4 w-full">
|
||||||
<Heading>Events</Heading>
|
<Heading>Events</Heading>
|
||||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||||
<div className="min-w-0 overflow-auto">
|
<div className="min-w-0 overflow-auto">
|
||||||
|
@ -72,7 +72,7 @@ export default function Recording({ camera, date, hour, seconds }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-2 px-4">
|
||||||
<Heading>{camera} Recordings</Heading>
|
<Heading>{camera} Recordings</Heading>
|
||||||
|
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
|
@ -25,7 +25,7 @@ export default function StyleGuide() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="p-2 px-4">
|
||||||
<Heading size="md">Button</Heading>
|
<Heading size="md">Button</Heading>
|
||||||
<div className="flex space-x-4 mb-4">
|
<div className="flex space-x-4 mb-4">
|
||||||
<Button>Default</Button>
|
<Button>Default</Button>
|
||||||
|
@ -8,6 +8,11 @@ export async function getCamera(url, cb, props) {
|
|||||||
return module.default;
|
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) {
|
export async function getEvent(url, cb, props) {
|
||||||
const module = await import('./Event.jsx');
|
const module = await import('./Event.jsx');
|
||||||
return module.default;
|
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 = {
|
module.exports = {
|
||||||
purge: ['./public/**/*.html', './src/**/*.jsx'],
|
purge: ['./public/**/*.html', './src/**/*.{jsx,tsx}', './src/utils/tailwind/*.{jsx,tsx,js,ts}'],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
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