feat: Timeline UI (#2830)

This commit is contained in:
JohnMark Sill 2022-02-27 08:04:12 -06:00 committed by GitHub
parent 4004048add
commit 3e07d4eddb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1950 additions and 50 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ web/build
web/node_modules web/node_modules
web/coverage web/coverage
core core
!/web/**/*.ts

View 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.

View File

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

View File

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

View File

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

@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2019",
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
}
}

749
web/package-lock.json generated
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

View File

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

View File

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

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

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

View File

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

View 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()} &middot;
</span>
);
}
return (
<div className={`text-center ${className}`}>
<Heading size='lg'>{title}</Heading>
<div>{subtitle}</div>
</div>
);
};

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

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

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

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

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

View File

@ -0,0 +1,4 @@
export interface ScrollPermission {
allowed: boolean;
resetAfterSeeked: boolean;
}

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

View 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`,
}}
/>
);
};

View 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;
};

View File

@ -0,0 +1,7 @@
import { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent: TimelineEvent;
markerTime: Date;
seekComplete: boolean;
}

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

View File

@ -0,0 +1,8 @@
export interface TimelineEvent {
start_time: number;
end_time: number;
startTime: Date;
endTime: Date;
id: string;
label: 'car' | 'person' | 'dog';
}

View File

@ -0,0 +1,9 @@
import { TimelineEvent } from './TimelineEvent';
export interface TimelineEventBlock extends TimelineEvent {
index: number;
yOffset: number;
width: number;
positionX: number;
seconds: number;
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View 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 />;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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());
};

View File

@ -0,0 +1 @@
export const isNullOrUndefined = (object?: unknown): boolean => object === null || object === undefined;

View 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';
};

View File

@ -0,0 +1,3 @@
export const convertRemToPixels = (rem: number): number => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}

View File

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

@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES2019",
"jsx": "react",
"jsxFactory": "h",
"lib": [
"ES2019"
]
},
"include": [
"./src/**/*.tsx",
"./src/**/*.ts"
]
}