mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
swr events refactor
This commit is contained in:
parent
4bae3993da
commit
1c9ba11e07
@ -9,19 +9,38 @@
|
|||||||
"mhutchie.git-graph",
|
"mhutchie.git-graph",
|
||||||
"ms-azuretools.vscode-docker",
|
"ms-azuretools.vscode-docker",
|
||||||
"streetsidesoftware.code-spell-checker",
|
"streetsidesoftware.code-spell-checker",
|
||||||
"eamodio.gitlens",
|
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"ms-python.vscode-pylance"
|
"ms-python.vscode-pylance",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"mikestead.dotenv",
|
||||||
|
"csstools.postcss",
|
||||||
|
"blanu.vscode-styled-jsx",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"python.pythonPath": "/usr/bin/python3",
|
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"python.linting.enabled": true,
|
"python.linting.enabled": true,
|
||||||
"python.formatting.provider": "black",
|
"python.formatting.provider": "black",
|
||||||
|
"python.languageServer": "Pylance",
|
||||||
"editor.formatOnPaste": false,
|
"editor.formatOnPaste": false,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnType": true,
|
"editor.formatOnType": true,
|
||||||
"files.trimTrailingWhitespace": true,
|
"files.trimTrailingWhitespace": true,
|
||||||
"terminal.integrated.shell.linux": "/bin/bash"
|
"eslint.workingDirectories": ["./web"],
|
||||||
|
"[json][jsonc]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[jsx][js][tsx][ts]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||||
|
"editor.codeActionsOnSave": [
|
||||||
|
"source.addMissingImports",
|
||||||
|
"source.fixAll.eslint"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cSpell.ignoreWords": ["rtmp"],
|
||||||
|
"cSpell.words": ["preact"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,6 @@ config/
|
|||||||
.git
|
.git
|
||||||
core
|
core
|
||||||
*.mp4
|
*.mp4
|
||||||
|
*.jpg
|
||||||
*.db
|
*.db
|
||||||
*.ts
|
*.ts
|
@ -20,6 +20,7 @@ services:
|
|||||||
- .:/lab/frigate:cached
|
- .:/lab/frigate:cached
|
||||||
- ./config/config.yml:/config/config.yml:ro
|
- ./config/config.yml:/config/config.yml:ro
|
||||||
- ./debug:/media/frigate
|
- ./debug:/media/frigate
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
@ -6,7 +6,7 @@ ARG USER_GID=$USER_UID
|
|||||||
|
|
||||||
# Create the user
|
# Create the user
|
||||||
RUN groupadd --gid $USER_GID $USERNAME \
|
RUN groupadd --gid $USER_GID $USERNAME \
|
||||||
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
|
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /bin/bash \
|
||||||
#
|
#
|
||||||
# [Optional] Add sudo support. Omit if you don't need to install software after connecting.
|
# [Optional] Add sudo support. Omit if you don't need to install software after connecting.
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
|
@ -173,7 +173,6 @@ http {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
add_header 'Access-Control-Allow-Origin' '*';
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
proxy_pass http://frigate_api/;
|
proxy_pass http://frigate_api/;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
@ -183,8 +183,11 @@ def delete_event(id):
|
|||||||
def event_thumbnail(id):
|
def event_thumbnail(id):
|
||||||
format = request.args.get("format", "ios")
|
format = request.args.get("format", "ios")
|
||||||
thumbnail_bytes = None
|
thumbnail_bytes = None
|
||||||
|
event_complete = False
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == id)
|
||||||
|
if not event.end_time is None:
|
||||||
|
event_complete = True
|
||||||
thumbnail_bytes = base64.b64decode(event.thumbnail)
|
thumbnail_bytes = base64.b64decode(event.thumbnail)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
@ -219,6 +222,8 @@ def event_thumbnail(id):
|
|||||||
|
|
||||||
response = make_response(thumbnail_bytes)
|
response = make_response(thumbnail_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
|
if event_complete:
|
||||||
|
response.headers["Cache-Control"] = "private, max-age=31536000"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@ -305,9 +310,9 @@ def event_clip(id):
|
|||||||
@bp.route("/events")
|
@bp.route("/events")
|
||||||
def events():
|
def events():
|
||||||
limit = request.args.get("limit", 100)
|
limit = request.args.get("limit", 100)
|
||||||
camera = request.args.get("camera")
|
camera = request.args.get("camera", "all")
|
||||||
label = request.args.get("label")
|
label = request.args.get("label", "all")
|
||||||
zone = request.args.get("zone")
|
zone = request.args.get("zone", "all")
|
||||||
after = request.args.get("after", type=float)
|
after = request.args.get("after", type=float)
|
||||||
before = request.args.get("before", type=float)
|
before = request.args.get("before", type=float)
|
||||||
has_clip = request.args.get("has_clip", type=int)
|
has_clip = request.args.get("has_clip", type=int)
|
||||||
@ -317,20 +322,20 @@ def events():
|
|||||||
clauses = []
|
clauses = []
|
||||||
excluded_fields = []
|
excluded_fields = []
|
||||||
|
|
||||||
if camera:
|
if camera != "all":
|
||||||
clauses.append((Event.camera == camera))
|
clauses.append((Event.camera == camera))
|
||||||
|
|
||||||
if label:
|
if label != "all":
|
||||||
clauses.append((Event.label == label))
|
clauses.append((Event.label == label))
|
||||||
|
|
||||||
if zone:
|
if zone != "all":
|
||||||
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
||||||
|
|
||||||
if after:
|
if after:
|
||||||
clauses.append((Event.start_time >= after))
|
clauses.append((Event.start_time > after))
|
||||||
|
|
||||||
if before:
|
if before:
|
||||||
clauses.append((Event.start_time <= before))
|
clauses.append((Event.start_time < before))
|
||||||
|
|
||||||
if not has_clip is None:
|
if not has_clip is None:
|
||||||
clauses.append((Event.has_clip == has_clip))
|
clauses.append((Event.has_clip == has_clip))
|
||||||
@ -648,7 +653,7 @@ def recording_clip(camera, start_ts, end_ts):
|
|||||||
"-safe",
|
"-safe",
|
||||||
"0",
|
"0",
|
||||||
"-i",
|
"-i",
|
||||||
"-",
|
"/dev/stdin",
|
||||||
"-c",
|
"-c",
|
||||||
"copy",
|
"copy",
|
||||||
"-movflags",
|
"-movflags",
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
build/*
|
build/*
|
||||||
node_modules/*
|
node_modules/*
|
||||||
|
src/env.js
|
162
web/.eslintrc.js
162
web/.eslintrc.js
@ -1,157 +1,39 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
parser: '@babel/eslint-parser',
|
parser: '@babel/eslint-parser',
|
||||||
|
extends: ['eslint:recommended', 'preact', 'prettier'],
|
||||||
|
plugins: ['react', 'jest'],
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
mocha: true,
|
||||||
|
es6: true,
|
||||||
|
'jest/globals': true,
|
||||||
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
ecmaFeatures: {
|
||||||
experimentalObjectRestSpread: true,
|
modules: true,
|
||||||
jsx: true,
|
jsx: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
extends: [
|
|
||||||
'prettier',
|
|
||||||
'preact',
|
|
||||||
'plugin:import/react',
|
|
||||||
'plugin:import/typescript',
|
|
||||||
'plugin:testing-library/recommended',
|
|
||||||
'plugin:jest/recommended',
|
|
||||||
],
|
|
||||||
plugins: ['import', 'testing-library', 'jest'],
|
|
||||||
|
|
||||||
env: {
|
|
||||||
es6: true,
|
|
||||||
node: true,
|
|
||||||
browser: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
'constructor-super': 'error',
|
|
||||||
'default-case': ['error', { commentPattern: '^no default$' }],
|
|
||||||
'handle-callback-err': ['error', '^(err|error)$'],
|
|
||||||
'new-cap': ['error', { newIsCap: true, capIsNew: false }],
|
|
||||||
'no-alert': 'error',
|
|
||||||
'no-array-constructor': 'error',
|
|
||||||
'no-caller': 'error',
|
|
||||||
'no-case-declarations': 'error',
|
|
||||||
'no-class-assign': 'error',
|
|
||||||
'no-cond-assign': 'error',
|
|
||||||
'no-console': 'error',
|
|
||||||
'no-const-assign': 'error',
|
|
||||||
'no-control-regex': 'error',
|
|
||||||
'no-debugger': 'error',
|
|
||||||
'no-delete-var': 'error',
|
|
||||||
'no-dupe-args': 'error',
|
|
||||||
'no-dupe-class-members': 'error',
|
|
||||||
'no-dupe-keys': 'error',
|
|
||||||
'no-duplicate-case': 'error',
|
|
||||||
'no-duplicate-imports': 'error',
|
|
||||||
'no-empty-character-class': 'error',
|
|
||||||
'no-empty-pattern': 'error',
|
|
||||||
'no-eval': 'error',
|
|
||||||
'no-ex-assign': 'error',
|
|
||||||
'no-extend-native': 'error',
|
|
||||||
'no-extra-bind': 'error',
|
|
||||||
'no-extra-boolean-cast': 'error',
|
|
||||||
'no-fallthrough': 'error',
|
|
||||||
'no-floating-decimal': 'error',
|
|
||||||
'no-func-assign': 'error',
|
|
||||||
'no-implied-eval': 'error',
|
|
||||||
'no-inner-declarations': ['error', 'functions'],
|
|
||||||
'no-invalid-regexp': 'error',
|
|
||||||
'no-irregular-whitespace': 'error',
|
|
||||||
'no-iterator': 'error',
|
|
||||||
'no-label-var': 'error',
|
|
||||||
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
|
|
||||||
'no-lone-blocks': 'error',
|
|
||||||
'no-loop-func': 'error',
|
|
||||||
'no-multi-str': 'error',
|
|
||||||
'no-native-reassign': 'error',
|
|
||||||
'no-negated-in-lhs': 'error',
|
|
||||||
'no-new': 'error',
|
|
||||||
'no-new-func': 'error',
|
|
||||||
'no-new-object': 'error',
|
|
||||||
'no-new-require': 'error',
|
|
||||||
'no-new-symbol': 'error',
|
|
||||||
'no-new-wrappers': 'error',
|
|
||||||
'no-obj-calls': 'error',
|
|
||||||
'no-octal': 'error',
|
|
||||||
'no-octal-escape': 'error',
|
|
||||||
'no-path-concat': 'error',
|
|
||||||
'no-proto': 'error',
|
|
||||||
'no-redeclare': 'error',
|
|
||||||
'no-regex-spaces': 'error',
|
|
||||||
'no-return-assign': ['error', 'except-parens'],
|
|
||||||
'no-script-url': 'error',
|
|
||||||
'no-self-assign': 'error',
|
|
||||||
'no-self-compare': 'error',
|
|
||||||
'no-sequences': 'error',
|
|
||||||
'no-shadow-restricted-names': 'error',
|
|
||||||
'no-sparse-arrays': 'error',
|
|
||||||
'no-this-before-super': 'error',
|
|
||||||
'no-throw-literal': 'error',
|
|
||||||
'no-trailing-spaces': 'error',
|
|
||||||
'no-undef': 'error',
|
|
||||||
'no-undef-init': 'error',
|
|
||||||
'no-unexpected-multiline': 'error',
|
|
||||||
'no-unmodified-loop-condition': 'error',
|
|
||||||
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
|
|
||||||
'no-unreachable': 'error',
|
|
||||||
'no-unsafe-finally': 'error',
|
|
||||||
'no-unused-vars': ['error', { vars: 'all', args: 'none', ignoreRestSiblings: true }],
|
|
||||||
'no-useless-call': 'error',
|
|
||||||
'no-useless-computed-key': 'error',
|
|
||||||
'no-useless-concat': 'error',
|
|
||||||
'no-useless-constructor': 'error',
|
|
||||||
'no-useless-escape': 'error',
|
|
||||||
'no-var': 'error',
|
|
||||||
'no-with': 'error',
|
|
||||||
'prefer-const': 'error',
|
|
||||||
'prefer-rest-params': 'error',
|
|
||||||
'use-isnan': 'error',
|
|
||||||
'valid-typeof': 'error',
|
|
||||||
camelcase: 'off',
|
|
||||||
eqeqeq: ['error', 'allow-null'],
|
|
||||||
indent: ['error', 2, { SwitchCase: 1 }],
|
|
||||||
quotes: ['error', 'single', 'avoid-escape'],
|
|
||||||
radix: 'error',
|
|
||||||
yoda: ['error', 'never'],
|
|
||||||
|
|
||||||
'import/no-unresolved': 'error',
|
|
||||||
|
|
||||||
// 'react-hooks/exhaustive-deps': 'error',
|
|
||||||
|
|
||||||
'jest/consistent-test-it': ['error', { fn: 'test' }],
|
|
||||||
'jest/no-test-prefixes': 'error',
|
|
||||||
'jest/no-restricted-matchers': [
|
|
||||||
'error',
|
|
||||||
{ toMatchSnapshot: 'Use `toMatchInlineSnapshot()` and ensure you only snapshot very small elements' },
|
|
||||||
],
|
|
||||||
'jest/valid-describe': 'error',
|
|
||||||
'jest/valid-expect-in-promise': 'error',
|
|
||||||
},
|
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
'import/resolver': {
|
react: {
|
||||||
node: {
|
pragma: 'h',
|
||||||
extensions: ['.js', '.jsx'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
sleep: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
indent: ['error', 2, { SwitchCase: 1 }],
|
||||||
|
'comma-dangle': ['error', { objects: 'always-multiline', arrays: 'always-multiline' }],
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint'],
|
||||||
extends: ['plugin:@typescript-eslint/recommended'],
|
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||||
settings: {
|
|
||||||
'import/resolver': {
|
|
||||||
node: {
|
|
||||||
extensions: ['.ts', '.tsx'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -7,6 +7,6 @@ module.exports = {
|
|||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
timers: 'fake',
|
timers: 'fake',
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js'
|
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"target": "ES2019",
|
"target": "ES2019",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxFactory": "h",
|
"jsxFactory": "h",
|
||||||
"jsxFragmentFactory": "Fragment",
|
"jsxFragmentFactory": "Fragment"
|
||||||
}
|
},
|
||||||
|
"include": ["./src/**/*.js", "./src/**/*.jsx"]
|
||||||
}
|
}
|
||||||
|
36654
web/package-lock.json
generated
36654
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -2,53 +2,57 @@
|
|||||||
"name": "frigate",
|
"name": "frigate",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
"start": "SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||||
"start:custom": "snowpack dev",
|
|
||||||
"prebuild": "rimraf build",
|
"prebuild": "rimraf build",
|
||||||
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
|
"build": "SNOWPACK_PUBLIC_API_HOST='' NODE_ENV=production SNOWPACK_MODE=production snowpack build",
|
||||||
"lint": "npm run lint:cmd -- --fix",
|
"lint": "npm run lint:cmd -- --fix",
|
||||||
"lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
"lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^5.0.1",
|
"@cycjimmy/jsmpeg-player": "^5.0.1",
|
||||||
|
"axios": "^0.26.0",
|
||||||
"date-fns": "^2.21.3",
|
"date-fns": "^2.21.3",
|
||||||
"idb-keyval": "^5.0.2",
|
"idb-keyval": "^5.0.2",
|
||||||
"immer": "^9.0.6",
|
"immer": "^9.0.6",
|
||||||
"preact": "^10.5.9",
|
"preact": "^10.6.6",
|
||||||
"preact-async-route": "^2.2.1",
|
"preact-async-route": "^2.2.1",
|
||||||
"preact-router": "^3.2.1",
|
"preact-router": "^4.0.1",
|
||||||
|
"swr": "^1.2.2",
|
||||||
"video.js": "^7.15.4",
|
"video.js": "^7.15.4",
|
||||||
"videojs-playlist": "^4.3.1",
|
"videojs-playlist": "^4.3.1",
|
||||||
"videojs-seek-buttons": "^2.0.1"
|
"videojs-seek-buttons": "^2.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/eslint-parser": "^7.12.13",
|
"@babel/eslint-parser": "^7.17.0",
|
||||||
"@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",
|
"@babel/preset-typescript": "^7.16.7",
|
||||||
"@prefresh/snowpack": "^3.0.1",
|
"@prefresh/snowpack": "^3.1.4",
|
||||||
"@snowpack/plugin-postcss": "^1.1.0",
|
"@snowpack/plugin-postcss": "^1.1.0",
|
||||||
|
"@snowpack/plugin-typescript": "^1.2.1",
|
||||||
|
"@tailwindcss/forms": "^0.4.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/eslint-plugin": "^5.12.0",
|
||||||
"@typescript-eslint/parser": "^5.12.0",
|
"@typescript-eslint/parser": "^5.12.0",
|
||||||
"autoprefixer": "^10.2.1",
|
"autoprefixer": "^10.4.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^7.19.0",
|
"eslint": "^8.10.0",
|
||||||
"eslint-config-preact": "^1.1.3",
|
"eslint-config-preact": "^1.3.0",
|
||||||
"eslint-config-prettier": "^7.2.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"eslint-plugin-import": "^2.25.4",
|
||||||
"eslint-plugin-jest": "^24.1.3",
|
"eslint-plugin-jest": "^26.1.1",
|
||||||
"eslint-plugin-testing-library": "^3.10.1",
|
"eslint-plugin-testing-library": "^5.0.5",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"postcss": "^8.2.10",
|
"postcss": "^8.4.7",
|
||||||
"postcss-cli": "^8.3.1",
|
"postcss-cli": "^8.3.1",
|
||||||
|
"preact-cli": "^3.3.5",
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"snowpack": "^3.0.11",
|
"snowpack": "^3.8.8",
|
||||||
"snowpack-plugin-hash": "^0.14.2",
|
"snowpack-plugin-hash": "^0.16.0",
|
||||||
"tailwindcss": "^2.0.2"
|
"tailwindcss": "^3.0.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
printWidth: 120,
|
printWidth: 120,
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
jsxSingleQuote: true,
|
|
||||||
useTabs: false,
|
|
||||||
};
|
};
|
||||||
|
@ -3,7 +3,10 @@ module.exports = {
|
|||||||
public: { url: '/', static: true },
|
public: { url: '/', static: true },
|
||||||
src: { url: '/dist' },
|
src: { url: '/dist' },
|
||||||
},
|
},
|
||||||
plugins: ['@snowpack/plugin-postcss', '@prefresh/snowpack', 'snowpack-plugin-hash'],
|
plugins: ['@prefresh/snowpack', '@snowpack/plugin-typescript', '@snowpack/plugin-postcss', 'snowpack-plugin-hash'],
|
||||||
|
devOptions: {
|
||||||
|
tailwindConfig: './tailwind.config.js',
|
||||||
|
},
|
||||||
routes: [{ match: 'all', src: '(?!.*(.svg|.gif|.json|.jpg|.jpeg|.png|.js)).*', dest: '/index.html' }],
|
routes: [{ match: 'all', src: '(?!.*(.svg|.gif|.json|.jpg|.jpeg|.png|.js)).*', dest: '/index.html' }],
|
||||||
optimize: {
|
optimize: {
|
||||||
bundle: false,
|
bundle: false,
|
||||||
@ -11,9 +14,22 @@ module.exports = {
|
|||||||
treeshake: true,
|
treeshake: true,
|
||||||
},
|
},
|
||||||
packageOptions: {
|
packageOptions: {
|
||||||
sourcemap: false,
|
knownEntrypoints: [
|
||||||
|
'@videojs/vhs-utils/es/stream.js',
|
||||||
|
'@videojs/vhs-utils/es/resolve-url.js',
|
||||||
|
'@videojs/vhs-utils/es/media-types.js',
|
||||||
|
'@videojs/vhs-utils/es/decode-b64-to-uint8-array.js',
|
||||||
|
'@videojs/vhs-utils/es/id3-helpers',
|
||||||
|
'@videojs/vhs-utils/es/byte-helpers',
|
||||||
|
'@videojs/vhs-utils/es/containers',
|
||||||
|
'@videojs/vhs-utils/es/codecs.js',
|
||||||
|
'global/window',
|
||||||
|
'global/document',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
buildOptions: {
|
buildOptions: {},
|
||||||
sourcemap: false,
|
alias: {
|
||||||
|
react: 'preact/compat',
|
||||||
|
'react-dom': 'preact/compat',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -7,17 +7,18 @@ import Cameras from './routes/Cameras';
|
|||||||
import { Router } from 'preact-router';
|
import { Router } from 'preact-router';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import { DarkModeProvider, DrawerProvider } from './context';
|
import { DarkModeProvider, DrawerProvider } from './context';
|
||||||
import { FetchStatus, useConfig } from './api';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { status, data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
const cameraComponent = config && config.ui.use_experimental ? Routes.getCameraV2 : Routes.getCamera;
|
const cameraComponent = config && config.ui.use_experimental ? Routes.getCameraV2 : Routes.getCamera;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DarkModeProvider>
|
<DarkModeProvider>
|
||||||
<DrawerProvider>
|
<DrawerProvider>
|
||||||
<div data-testid="app" className="w-full">
|
<div data-testid="app" className="w-full">
|
||||||
<AppBar />
|
<AppBar />
|
||||||
{status !== FetchStatus.LOADED ? (
|
{!config ? (
|
||||||
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@ export default function AppBar() {
|
|||||||
const { send: sendRestart } = useRestart();
|
const { send: sendRestart } = useRestart();
|
||||||
|
|
||||||
const handleSelectDarkMode = useCallback(
|
const handleSelectDarkMode = useCallback(
|
||||||
(value, label) => {
|
(value) => {
|
||||||
setDarkMode(value);
|
setDarkMode(value);
|
||||||
setShowMoreMenu(false);
|
setShowMoreMenu(false);
|
||||||
},
|
},
|
||||||
|
@ -3,12 +3,12 @@ import LinkedLogo from './components/LinkedLogo';
|
|||||||
import { Match } from 'preact-router/match';
|
import { Match } from 'preact-router/match';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { ENV } from './env';
|
import { ENV } from './env';
|
||||||
import { useConfig } from './api';
|
import useSWR from 'swr';
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
|
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
|
||||||
const { birdseye } = config;
|
const { birdseye } = config;
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export default function Sidebar() {
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<Separator />
|
<Separator />
|
||||||
{cameras.map(([camera]) => (
|
{cameras.map(([camera]) => (
|
||||||
<Destination href={`/cameras/${camera}`} text={camera} />
|
<Destination key={camera} href={`/cameras/${camera}`} text={camera} />
|
||||||
))}
|
))}
|
||||||
<Separator />
|
<Separator />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
@ -37,7 +37,7 @@ describe('useFetch', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
||||||
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => {
|
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url) => {
|
||||||
if (url.endsWith('/api/config')) {
|
if (url.endsWith('/api/config')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ function Test() {
|
|||||||
return state.__connected ? (
|
return state.__connected ? (
|
||||||
<div data-testid="data">
|
<div data-testid="data">
|
||||||
{Object.keys(state).map((key) => (
|
{Object.keys(state).map((key) => (
|
||||||
<div data-testid={key}>{JSON.stringify(state[key])}</div>
|
<div key={key} data-testid={key}>
|
||||||
|
{JSON.stringify(state[key])}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
@ -28,10 +30,10 @@ describe('MqttProvider', () => {
|
|||||||
return new Proxy(
|
return new Proxy(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
get(target, prop, receiver) {
|
get(_target, prop, _receiver) {
|
||||||
return wsClient[prop];
|
return wsClient[prop];
|
||||||
},
|
},
|
||||||
set(target, prop, value) {
|
set(_target, prop, value) {
|
||||||
wsClient[prop] = typeof value === 'function' ? jest.fn(value) : value;
|
wsClient[prop] = typeof value === 'function' ? jest.fn(value) : value;
|
||||||
if (prop === 'onopen') {
|
if (prop === 'onopen') {
|
||||||
wsClient[prop]();
|
wsClient[prop]();
|
||||||
@ -121,12 +123,24 @@ describe('MqttProvider', () => {
|
|||||||
</MqttProvider>
|
</MqttProvider>
|
||||||
);
|
);
|
||||||
await screen.findByTestId('data');
|
await screen.findByTestId('data');
|
||||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
|
||||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
'{"lastUpdate":123456,"payload":"ON","retain":true}'
|
||||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
);
|
||||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent(
|
||||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
|
||||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
);
|
||||||
|
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent(
|
||||||
|
'{"lastUpdate":123456,"payload":"ON","retain":true}'
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('side/detect/state')).toHaveTextContent(
|
||||||
|
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent(
|
||||||
|
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent(
|
||||||
|
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,166 +1,28 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
import { baseUrl } from './baseUrl';
|
import { baseUrl } from './baseUrl';
|
||||||
import { h, createContext } from 'preact';
|
import useSWR, { SWRConfig } from 'swr';
|
||||||
import { MqttProvider } from './mqtt';
|
import { MqttProvider } from './mqtt';
|
||||||
import produce from 'immer';
|
import axios from 'axios';
|
||||||
import { useContext, useEffect, useReducer } from 'preact/hooks';
|
|
||||||
|
|
||||||
export const FetchStatus = {
|
axios.defaults.baseURL = `${baseUrl}/api/`;
|
||||||
NONE: 'none',
|
|
||||||
LOADING: 'loading',
|
|
||||||
LOADED: 'loaded',
|
|
||||||
ERROR: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = Object.freeze({
|
|
||||||
host: baseUrl,
|
|
||||||
queries: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const Api = createContext(initialState);
|
|
||||||
|
|
||||||
function reducer(state, { type, payload }) {
|
|
||||||
switch (type) {
|
|
||||||
case 'REQUEST': {
|
|
||||||
const { url, fetchId } = payload;
|
|
||||||
const data = state.queries[url]?.data || null;
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
draftState.queries[url] = { status: FetchStatus.LOADING, data, fetchId };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'RESPONSE': {
|
|
||||||
const { url, ok, data, fetchId } = payload;
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data, fetchId };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case 'DELETE': {
|
|
||||||
const { eventId } = payload;
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
Object.keys(draftState.queries).map((url) => {
|
|
||||||
draftState.queries[url].deletedId = eventId;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApiProvider({ children }) {
|
export function ApiProvider({ children }) {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
|
||||||
return (
|
return (
|
||||||
<Api.Provider value={{ state, dispatch }}>
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
fetcher: (path) => axios.get(path).then((res) => res.data),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<MqttWithConfig>{children}</MqttWithConfig>
|
<MqttWithConfig>{children}</MqttWithConfig>
|
||||||
</Api.Provider>
|
</SWRConfig>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MqttWithConfig({ children }) {
|
function MqttWithConfig({ children }) {
|
||||||
const { data, status } = useConfig();
|
const { data } = useSWR('config');
|
||||||
return status === FetchStatus.LOADED ? <MqttProvider config={data}>{children}</MqttProvider> : children;
|
return data ? <MqttProvider config={data}>{children}</MqttProvider> : children;
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFetch(state, url, fetchId = null) {
|
|
||||||
if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const { status } = state.queries[url];
|
|
||||||
|
|
||||||
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFetch(url, fetchId) {
|
|
||||||
const { state, dispatch } = useContext(Api);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldFetch(state, url, fetchId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
await dispatch({ type: 'REQUEST', payload: { url, fetchId } });
|
|
||||||
const response = await fetch(`${state.host}${url}`);
|
|
||||||
try {
|
|
||||||
const data = await response.json();
|
|
||||||
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data, fetchId } });
|
|
||||||
} catch (e) {
|
|
||||||
await dispatch({ type: 'RESPONSE', payload: { url, ok: false, data: null, fetchId } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [url, fetchId, state, dispatch]);
|
|
||||||
|
|
||||||
if (!(url in state.queries)) {
|
|
||||||
return { data: null, status: FetchStatus.NONE };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = state.queries[url].data || null;
|
|
||||||
const status = state.queries[url].status;
|
|
||||||
const deletedId = state.queries[url].deletedId || 0;
|
|
||||||
|
|
||||||
return { data, status, deletedId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDelete() {
|
|
||||||
const { dispatch, state } = useContext(Api);
|
|
||||||
|
|
||||||
async function deleteEvent(eventId) {
|
|
||||||
if (!eventId) return null;
|
|
||||||
|
|
||||||
const response = await fetch(`${state.host}/api/events/${eventId}`, { method: 'DELETE' });
|
|
||||||
await dispatch({ type: 'DELETE', payload: { eventId } });
|
|
||||||
return await (response.status < 300 ? response.json() : { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleteEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRetain() {
|
|
||||||
const { state } = useContext(Api);
|
|
||||||
|
|
||||||
async function retainEvent(eventId, shouldRetain) {
|
|
||||||
if (!eventId) return null;
|
|
||||||
|
|
||||||
if (shouldRetain) {
|
|
||||||
const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'POST' });
|
|
||||||
return await (response.status < 300 ? response.json() : { success: true });
|
|
||||||
} else {
|
|
||||||
const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'DELETE' });
|
|
||||||
return await (response.status < 300 ? response.json() : { success: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return retainEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApiHost() {
|
export function useApiHost() {
|
||||||
const { state } = useContext(Api);
|
return baseUrl;
|
||||||
return state.host;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEvents(searchParams, fetchId) {
|
|
||||||
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
|
|
||||||
return useFetch(url, fetchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEvent(eventId, fetchId) {
|
|
||||||
const url = `/api/events/${eventId}`;
|
|
||||||
return useFetch(url, fetchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRecording(camera, fetchId) {
|
|
||||||
const url = `/api/${camera}/recordings`;
|
|
||||||
return useFetch(url, fetchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConfig(searchParams, fetchId) {
|
|
||||||
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
|
||||||
return useFetch(url, fetchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStats(searchParams, fetchId) {
|
|
||||||
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
|
|
||||||
return useFetch(url, fetchId);
|
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,6 @@ export default function Button({
|
|||||||
color = 'blue',
|
color = 'blue',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
href,
|
href,
|
||||||
size,
|
|
||||||
type = 'contained',
|
type = 'contained',
|
||||||
...attrs
|
...attrs
|
||||||
}) {
|
}) {
|
||||||
@ -81,11 +80,11 @@ export default function Button({
|
|||||||
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMousenter = useCallback((event) => {
|
const handleMousenter = useCallback(() => {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseleave = useCallback((event) => {
|
const handleMouseleave = useCallback(() => {
|
||||||
setHovered(false);
|
setHovered(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -7,27 +7,35 @@ export default function ButtonsTabbed({
|
|||||||
setHeader = null,
|
setHeader = null,
|
||||||
headers = [''],
|
headers = [''],
|
||||||
className = 'text-gray-600 py-0 px-4 block hover:text-gray-500',
|
className = 'text-gray-600 py-0 px-4 block hover:text-gray-500',
|
||||||
selectedClassName = `${className} focus:outline-none border-b-2 font-medium border-gray-500`
|
selectedClassName = `${className} focus:outline-none border-b-2 font-medium border-gray-500`,
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
const captitalize = (str) => { return (`${str.charAt(0).toUpperCase()}${str.slice(1)}`); };
|
const captitalize = (str) => {
|
||||||
|
return `${str.charAt(0).toUpperCase()}${str.slice(1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const getHeader = useCallback((i) => {
|
const getHeader = useCallback(
|
||||||
return (headers.length === viewModes.length ? headers[i] : captitalize(viewModes[i]));
|
(i) => {
|
||||||
}, [headers, viewModes]);
|
return headers.length === viewModes.length ? headers[i] : captitalize(viewModes[i]);
|
||||||
|
},
|
||||||
|
[headers, viewModes]
|
||||||
|
);
|
||||||
|
|
||||||
const handleClick = useCallback((i) => {
|
const handleClick = useCallback(
|
||||||
setSelected(i);
|
(i) => {
|
||||||
setViewMode && setViewMode(viewModes[i]);
|
setSelected(i);
|
||||||
setHeader && setHeader(getHeader(i));
|
setViewMode && setViewMode(viewModes[i]);
|
||||||
}, [setViewMode, setHeader, setSelected, viewModes, getHeader]);
|
setHeader && setHeader(getHeader(i));
|
||||||
|
},
|
||||||
|
[setViewMode, setHeader, setSelected, viewModes, getHeader]
|
||||||
|
);
|
||||||
|
|
||||||
setHeader && setHeader(getHeader(selected));
|
setHeader && setHeader(getHeader(selected));
|
||||||
return (
|
return (
|
||||||
<nav className="flex justify-end">
|
<nav className="flex justify-end">
|
||||||
{viewModes.map((item, i) => {
|
{viewModes.map((item, i) => {
|
||||||
return (
|
return (
|
||||||
<button onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
|
<button key={i} onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
|
||||||
{captitalize(item)}
|
{captitalize(item)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -5,7 +5,7 @@ import ArrowRightDouble from '../icons/ArrowRightDouble';
|
|||||||
|
|
||||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||||
|
|
||||||
const Calender = ({ onChange, calenderRef, close }) => {
|
const Calendar = ({ onChange, calendarRef, close }) => {
|
||||||
const keyRef = useRef([]);
|
const keyRef = useRef([]);
|
||||||
|
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
@ -159,8 +159,8 @@ const Calender = ({ onChange, calenderRef, close }) => {
|
|||||||
after,
|
after,
|
||||||
before:
|
before:
|
||||||
day.timestamp >= todayTimestamp
|
day.timestamp >= todayTimestamp
|
||||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,26 +243,26 @@ const Calender = ({ onChange, calenderRef, close }) => {
|
|||||||
const days =
|
const days =
|
||||||
state.monthDetails &&
|
state.monthDetails &&
|
||||||
state.monthDetails.map((day, idx) => {
|
state.monthDetails.map((day, idx) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onDateClick(day)}
|
onClick={() => onDateClick(day)}
|
||||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||||
tabIndex={day.month === 0 ? day.date : null}
|
tabIndex={day.month === 0 ? day.date : null}
|
||||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||||
}
|
}
|
||||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||||
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||||
key={idx}
|
key={idx}
|
||||||
>
|
>
|
||||||
<div className="font-light">
|
<div className="font-light">
|
||||||
<span className="text-gray-400">{day.date}</span>
|
<span className="text-gray-400">{day.date}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -280,7 +280,7 @@ const Calender = ({ onChange, calenderRef, close }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
|
<div className="select-none w-96 flex flex-shrink" ref={calendarRef}>
|
||||||
<div className="py-4 px-6">
|
<div className="py-4 px-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-1/6 relative flex justify-around">
|
<div className="w-1/6 relative flex justify-around">
|
||||||
@ -326,4 +326,4 @@ const Calender = ({ onChange, calenderRef, close }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Calender;
|
export default Calendar;
|
@ -1,11 +1,12 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import ActivityIndicator from './ActivityIndicator';
|
import ActivityIndicator from './ActivityIndicator';
|
||||||
import { useApiHost, useConfig } from '../api';
|
import { useApiHost } from '../api';
|
||||||
|
import useSWR from 'swr';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useResizeObserver } from '../hooks';
|
import { useResizeObserver } from '../hooks';
|
||||||
|
|
||||||
export default function CameraImage({ camera, onload, searchParams = '', stretch = false }) {
|
export default function CameraImage({ camera, onload, searchParams = '', stretch = false }) {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
@ -66,7 +66,6 @@ export default function DatePicker({
|
|||||||
onBlur,
|
onBlur,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onFocus,
|
onFocus,
|
||||||
readonly,
|
|
||||||
trailingIcon: TrailingIcon,
|
trailingIcon: TrailingIcon,
|
||||||
value: propValue = '',
|
value: propValue = '',
|
||||||
...props
|
...props
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import Heading from '../Heading';
|
import Heading from '../Heading';
|
||||||
import { TimelineEvent } from '../Timeline/Timeline';
|
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||||
|
|
||||||
interface HistoryHeaderProps {
|
interface HistoryHeaderProps {
|
||||||
event: TimelineEvent;
|
event: TimelineEvent;
|
||||||
|
@ -1,17 +1,29 @@
|
|||||||
import { Fragment, h } from 'preact';
|
import { Fragment, h } from 'preact';
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
import { useEvents } from '../../api';
|
import useSWR from 'swr';
|
||||||
import { useSearchString } from '../../hooks/useSearchString';
|
import axios from 'axios';
|
||||||
import { getNowYesterdayInLong } from '../../utils/dateUtil';
|
|
||||||
import Timeline from '../Timeline/Timeline';
|
import Timeline from '../Timeline/Timeline';
|
||||||
import { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
|
import type { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
|
||||||
import { TimelineEvent } from '../Timeline/TimelineEvent';
|
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||||
import { HistoryHeader } from './HistoryHeader';
|
import { HistoryHeader } from './HistoryHeader';
|
||||||
import { HistoryVideo } from './HistoryVideo';
|
import { HistoryVideo } from './HistoryVideo';
|
||||||
|
|
||||||
export default function HistoryViewer({ camera }) {
|
export default function HistoryViewer({ camera }) {
|
||||||
const { searchString } = useSearchString(500, `camera=${camera}&after=${getNowYesterdayInLong()}`);
|
const searchParams = {
|
||||||
const { data: events } = useEvents(searchString);
|
before: null,
|
||||||
|
after: null,
|
||||||
|
camera,
|
||||||
|
label: 'all',
|
||||||
|
zone: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: refactor
|
||||||
|
const eventsFetcher = (path, params) => {
|
||||||
|
params = { ...params, include_thumbnails: 0, limit: 500 };
|
||||||
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: events } = useSWR(['events', searchParams], eventsFetcher);
|
||||||
|
|
||||||
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
|
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
|
||||||
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
|
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
|
||||||
|
@ -5,7 +5,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
|
|||||||
|
|
||||||
export default function JSMpegPlayer({ camera, width, height }) {
|
export default function JSMpegPlayer({ camera, width, height }) {
|
||||||
const playerRef = useRef();
|
const playerRef = useRef();
|
||||||
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`
|
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = new JSMpeg.VideoElement(
|
const video = new JSMpeg.VideoElement(
|
||||||
@ -16,15 +16,15 @@ export default function JSMpegPlayer({ camera, width, height }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fullscreen = () => {
|
const fullscreen = () => {
|
||||||
if(video.els.canvas.webkitRequestFullScreen) {
|
if (video.els.canvas.webkitRequestFullScreen) {
|
||||||
video.els.canvas.webkitRequestFullScreen();
|
video.els.canvas.webkitRequestFullScreen();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
video.els.canvas.mozRequestFullScreen();
|
video.els.canvas.mozRequestFullScreen();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
video.els.canvas.addEventListener('click',fullscreen)
|
video.els.canvas.addEventListener('click',fullscreen);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.destroy();
|
video.destroy();
|
||||||
|
@ -16,18 +16,22 @@ export default function Menu({ className, children, onDismiss, relativeTo, width
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
|
export function MenuItem({ focus, icon: Icon, label, href, onSelect, value, ...attrs }) {
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect && onSelect(value, label);
|
onSelect && onSelect(value, label);
|
||||||
}, [onSelect, value, label]);
|
}, [onSelect, value, label]);
|
||||||
|
|
||||||
|
const Element = href ? 'a' : 'div';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Element
|
||||||
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
|
className={`flex space-x-2 p-2 px-5 hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer ${
|
||||||
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
|
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
|
||||||
}`}
|
}`}
|
||||||
|
href={href}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
role="option"
|
role="option"
|
||||||
|
{...attrs}
|
||||||
>
|
>
|
||||||
{Icon ? (
|
{Icon ? (
|
||||||
<div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
|
<div className="w-6 h-6 self-center mr-4 text-gray-500 flex-shrink-0">
|
||||||
@ -35,7 +39,7 @@ export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="whitespace-nowrap">{label}</div>
|
<div className="whitespace-nowrap">{label}</div>
|
||||||
</div>
|
</Element>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ export function Destination({ className = '', href, text, ...other }) {
|
|||||||
|
|
||||||
const styleProps = {
|
const styleProps = {
|
||||||
[external
|
[external
|
||||||
? 'className'
|
? className
|
||||||
: 'class']: 'block p-2 text-sm font-semibold text-gray-900 rounded hover:bg-blue-500 dark:text-gray-200 hover:text-white dark:hover:text-white focus:outline-none ring-opacity-50 focus:ring-2 ring-blue-300',
|
: 'class']: 'block p-2 text-sm font-semibold text-gray-900 rounded hover:bg-blue-500 dark:text-gray-200 hover:text-white dark:hover:text-white focus:outline-none ring-opacity-50 focus:ring-2 ring-blue-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
parseISO,
|
parseISO,
|
||||||
startOfHour,
|
startOfHour,
|
||||||
differenceInMinutes,
|
differenceInMinutes,
|
||||||
differenceInHours,
|
differenceInHours
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||||
import ArrowDropup from '../icons/ArrowDropup';
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
@ -16,7 +16,7 @@ import Menu from '../icons/Menu';
|
|||||||
import MenuOpen from '../icons/MenuOpen';
|
import MenuOpen from '../icons/MenuOpen';
|
||||||
import { useApiHost } from '../api';
|
import { useApiHost } from '../api';
|
||||||
|
|
||||||
export default function RecordingPlaylist({ camera, recordings, selectedDate, selectedHour }) {
|
export default function RecordingPlaylist({ camera, recordings, selectedDate }) {
|
||||||
const [active, setActive] = useState(true);
|
const [active, setActive] = useState(true);
|
||||||
const toggle = () => setActive(!active);
|
const toggle = () => setActive(!active);
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
|||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((item, i) => (
|
.map((item, i) => (
|
||||||
<div className="mb-2 w-full">
|
<div key={i} className="mb-2 w-full">
|
||||||
<div
|
<div
|
||||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||||
@ -50,7 +50,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
|||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((event) => (
|
.map((event) => (
|
||||||
<EventCard camera={camera} event={event} delay={item.delay} />
|
<EventCard key={event.id} camera={camera} event={event} delay={item.delay} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -110,7 +110,7 @@ export default function RelativeModal({
|
|||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div data-testid="scrim" key="scrim" className="absolute inset-0 z-10" onClick={handleDismiss} />
|
<div data-testid="scrim" key="scrim" className="fixed inset-0 z-10" onClick={handleDismiss} />
|
||||||
<div
|
<div
|
||||||
key="menu"
|
key="menu"
|
||||||
className={`z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-transform transition-opacity duration-75 transform scale-90 opacity-0 overflow-x-hidden overflow-y-auto ${
|
className={`z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-transform transition-opacity duration-75 transform scale-90 opacity-0 overflow-x-hidden overflow-y-auto ${
|
||||||
|
@ -4,7 +4,7 @@ import ArrowDropup from '../icons/ArrowDropup';
|
|||||||
import Menu, { MenuItem } from './Menu';
|
import Menu, { MenuItem } from './Menu';
|
||||||
import TextField from './TextField';
|
import TextField from './TextField';
|
||||||
import DatePicker from './DatePicker';
|
import DatePicker from './DatePicker';
|
||||||
import Calender from './Calender';
|
import Calendar from './Calendar';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
export default function Select({
|
export default function Select({
|
||||||
@ -71,8 +71,8 @@ export default function Select({
|
|||||||
}, [type, options, inputOptions, propSelected, setSelected]);
|
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||||
|
|
||||||
const [focused, setFocused] = useState(null);
|
const [focused, setFocused] = useState(null);
|
||||||
const [showCalender, setShowCalender] = useState(false);
|
const [showCalendar, setShowCalendar] = useState(false);
|
||||||
const calenderRef = useRef(null);
|
const calendarRef = useRef(null);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
@ -80,8 +80,8 @@ export default function Select({
|
|||||||
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
|
|
||||||
//show calender date range picker
|
//show calendar date range picker
|
||||||
if (value === 'custom_range') return setShowCalender(true);
|
if (value === 'custom_range') return setShowCalendar(true);
|
||||||
onChange && onChange(value);
|
onChange && onChange(value);
|
||||||
},
|
},
|
||||||
[onChange, options, propSelected, setSelected]
|
[onChange, options, propSelected, setSelected]
|
||||||
@ -110,7 +110,7 @@ export default function Select({
|
|||||||
setSelected(focused);
|
setSelected(focused);
|
||||||
if (options[focused].value === 'custom_range') {
|
if (options[focused].value === 'custom_range') {
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
return setShowCalender(true);
|
return setShowCalendar(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange && onChange(options[focused].value);
|
onChange && onChange(options[focused].value);
|
||||||
@ -184,8 +184,8 @@ export default function Select({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const addBackDrop = (e) => {
|
const addBackDrop = (e) => {
|
||||||
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
|
if (showCalendar && !findDOMNodes(calendarRef.current).contains(e.target)) {
|
||||||
setShowCalender(false);
|
setShowCalendar(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('click', addBackDrop);
|
window.addEventListener('click', addBackDrop);
|
||||||
@ -193,7 +193,7 @@ export default function Select({
|
|||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
window.removeEventListener('click', addBackDrop);
|
window.removeEventListener('click', addBackDrop);
|
||||||
};
|
};
|
||||||
}, [showCalender]);
|
}, [showCalendar]);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'datepicker':
|
case 'datepicker':
|
||||||
@ -208,9 +208,9 @@ export default function Select({
|
|||||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||||
value={datePickerValue}
|
value={datePickerValue}
|
||||||
/>
|
/>
|
||||||
{showCalender && (
|
{showCalendar && (
|
||||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||||
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
|
<Calendar onChange={handleDateRange} calendarRef={calendarRef} close={() => setShowCalendar(false)} />
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
{showMenu ? (
|
{showMenu ? (
|
||||||
@ -223,7 +223,7 @@ export default function Select({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|
||||||
// case 'dropdown':
|
// case 'dropdown':
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
@ -4,14 +4,11 @@ import { useCallback, useState } from 'preact/hooks';
|
|||||||
export default function Switch({ checked, id, onChange, label, labelPosition = 'before' }) {
|
export default function Switch({ checked, id, onChange, label, labelPosition = 'before' }) {
|
||||||
const [isFocused, setFocused] = useState(false);
|
const [isFocused, setFocused] = useState(false);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(() => {
|
||||||
(event) => {
|
if (onChange) {
|
||||||
if (onChange) {
|
onChange(id, !checked);
|
||||||
onChange(id, !checked);
|
}
|
||||||
}
|
}, [id, onChange, checked]);
|
||||||
},
|
|
||||||
[id, onChange, checked]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
onChange && setFocused(true);
|
onChange && setFocused(true);
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Fragment, h } from 'preact';
|
import { Fragment, h } from 'preact';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils';
|
import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils';
|
||||||
import { ScrollPermission } from './ScrollPermission';
|
import type { ScrollPermission } from './ScrollPermission';
|
||||||
import { TimelineBlocks } from './TimelineBlocks';
|
import { TimelineBlocks } from './TimelineBlocks';
|
||||||
import { TimelineChangeEvent } from './TimelineChangeEvent';
|
import type { TimelineChangeEvent } from './TimelineChangeEvent';
|
||||||
import { DisabledControls, TimelineControls } from './TimelineControls';
|
import { DisabledControls, TimelineControls } from './TimelineControls';
|
||||||
import { TimelineEvent } from './TimelineEvent';
|
import type { TimelineEvent } from './TimelineEvent';
|
||||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
events: TimelineEvent[];
|
events: TimelineEvent[];
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useCallback } from 'preact/hooks';
|
import { useCallback } from 'preact/hooks';
|
||||||
import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil';
|
import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil';
|
||||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||||
|
|
||||||
interface TimelineBlockViewProps {
|
interface TimelineBlockViewProps {
|
||||||
block: TimelineEventBlock;
|
block: TimelineEventBlock;
|
||||||
|
@ -3,7 +3,7 @@ import { useMemo } from 'preact/hooks';
|
|||||||
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
|
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
|
||||||
import { convertRemToPixels } from '../../utils/windowUtils';
|
import { convertRemToPixels } from '../../utils/windowUtils';
|
||||||
import { TimelineBlockView } from './TimelineBlockView';
|
import { TimelineBlockView } from './TimelineBlockView';
|
||||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||||
|
|
||||||
interface TimelineBlocksProps {
|
interface TimelineBlocksProps {
|
||||||
timeline: TimelineEventBlock[];
|
timeline: TimelineEventBlock[];
|
||||||
@ -36,7 +36,7 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
|||||||
...block,
|
...block,
|
||||||
yOffset: block.yOffset + timelineBlockOffset,
|
yOffset: block.yOffset + timelineBlockOffset,
|
||||||
};
|
};
|
||||||
return <TimelineBlockView block={updatedBlock} onClick={onClickHandler} />;
|
return <TimelineBlockView key={block.id} block={updatedBlock} onClick={onClickHandler} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TimelineEvent } from './TimelineEvent';
|
import type { TimelineEvent } from './TimelineEvent';
|
||||||
|
|
||||||
export interface TimelineChangeEvent {
|
export interface TimelineChangeEvent {
|
||||||
timelineEvent: TimelineEvent;
|
timelineEvent: TimelineEvent;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TimelineEvent } from './TimelineEvent';
|
import type { TimelineEvent } from './TimelineEvent';
|
||||||
|
|
||||||
export interface TimelineEventBlock extends TimelineEvent {
|
export interface TimelineEventBlock extends TimelineEvent {
|
||||||
index: number;
|
index: number;
|
||||||
|
@ -6,14 +6,14 @@ describe('Tooltip', () => {
|
|||||||
test('renders in a relative position', async () => {
|
test('renders in a relative position', async () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
// relativeTo
|
// relativeTo
|
||||||
.mockReturnValueOnce({
|
.mockReturnValueOnce({
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 10,
|
height: 10,
|
||||||
})
|
})
|
||||||
// tooltip
|
// tooltip
|
||||||
.mockReturnValueOnce({ width: 40, height: 15 });
|
.mockReturnValueOnce({ width: 40, height: 15 });
|
||||||
|
|
||||||
const ref = createRef();
|
const ref = createRef();
|
||||||
@ -34,14 +34,14 @@ describe('Tooltip', () => {
|
|||||||
window.innerWidth = 1024;
|
window.innerWidth = 1024;
|
||||||
jest
|
jest
|
||||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
// relativeTo
|
// relativeTo
|
||||||
.mockReturnValueOnce({
|
.mockReturnValueOnce({
|
||||||
x: 1000,
|
x: 1000,
|
||||||
y: 100,
|
y: 100,
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 10,
|
height: 10,
|
||||||
})
|
})
|
||||||
// tooltip
|
// tooltip
|
||||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||||
|
|
||||||
const ref = createRef();
|
const ref = createRef();
|
||||||
@ -61,14 +61,14 @@ describe('Tooltip', () => {
|
|||||||
test('if too far left, renders to the right', async () => {
|
test('if too far left, renders to the right', async () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
// relativeTo
|
// relativeTo
|
||||||
.mockReturnValueOnce({
|
.mockReturnValueOnce({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 100,
|
y: 100,
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 10,
|
height: 10,
|
||||||
})
|
})
|
||||||
// tooltip
|
// tooltip
|
||||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||||
|
|
||||||
const ref = createRef();
|
const ref = createRef();
|
||||||
@ -89,14 +89,14 @@ describe('Tooltip', () => {
|
|||||||
window.scrollY = 90;
|
window.scrollY = 90;
|
||||||
jest
|
jest
|
||||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
// relativeTo
|
// relativeTo
|
||||||
.mockReturnValueOnce({
|
.mockReturnValueOnce({
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 10,
|
height: 10,
|
||||||
})
|
})
|
||||||
// tooltip
|
// tooltip
|
||||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||||
|
|
||||||
const ref = createRef();
|
const ref = createRef();
|
||||||
|
24
web/src/icons/Calendar.jsx
Normal file
24
web/src/icons/Calendar.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Calendar({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Calendar);
|
24
web/src/icons/Camera.jsx
Normal file
24
web/src/icons/Camera.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Camera({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Camera);
|
@ -1,11 +1,22 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function Clip({ className = '' }) {
|
export function Clip({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||||
return (
|
return (
|
||||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
<svg
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z" />
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function Delete({ className = '' }) {
|
export function Delete({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
return (
|
return (
|
||||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
<svg
|
||||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="M6 21h12V7H6v14zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
24
web/src/icons/Download.jsx
Normal file
24
web/src/icons/Download.jsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Download({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Download);
|
@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function Play({ className = '' }) {
|
export function Play() {
|
||||||
return (
|
return (
|
||||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function Previous({ className = '' }) {
|
export function Previous() {
|
||||||
return (
|
return (
|
||||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M20,5V19L13,12M6,5V19H4V5M13,5V19L6,12" />
|
<path fill="currentColor" d="M20,5V19L13,12M6,5V19H4V5M13,5V19L6,12" />
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function Snapshot({ className = '' }) {
|
export function Snapshot({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||||
return (
|
return (
|
||||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
<svg
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<circle cx="12" cy="12" r="3.2" />
|
className={className}
|
||||||
<path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,22 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
export function StarRecording({ className = '' }) {
|
export function StarRecording({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
return (
|
return (
|
||||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 00-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6m.5 16.9L12 17.5 9.5 19l.7-2.8L8 14.3l2.9-.2 1.1-2.7 1.1 2.6 2.9.2-2.2 1.9.7 2.8M13 9V3.5L18.5 9H13z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
25
web/src/icons/Zone.jsx
Normal file
25
web/src/icons/Zone.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Zone({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke={stroke}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Zone);
|
@ -30,22 +30,13 @@
|
|||||||
position: static !important;
|
position: static !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
Event.js
|
|
||||||
Maintain aspect ratio and scale down the video container
|
|
||||||
Could not find a proper tailwind css.
|
|
||||||
*/
|
|
||||||
.outer-max-width {
|
|
||||||
max-width: 70%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide-scroll::-webkit-scrollbar {
|
.hide-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide-scroll {
|
.hide-scroll {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -10,18 +10,19 @@ import Switch from '../components/Switch';
|
|||||||
import ButtonsTabbed from '../components/ButtonsTabbed';
|
import ButtonsTabbed from '../components/ButtonsTabbed';
|
||||||
import { usePersistence } from '../context';
|
import { usePersistence } from '../context';
|
||||||
import { useCallback, useMemo, useState } from 'preact/hooks';
|
import { useCallback, useMemo, useState } from 'preact/hooks';
|
||||||
import { useApiHost, useConfig } from '../api';
|
import { useApiHost } from '../api';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const emptyObject = Object.freeze({});
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
export default function Camera({ camera }) {
|
export default function Camera({ camera }) {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState('live');
|
const [viewMode, setViewMode] = useState('live');
|
||||||
|
|
||||||
const cameraConfig = config?.cameras[camera];
|
const cameraConfig = config?.cameras[camera];
|
||||||
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
|
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
|
||||||
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
||||||
|
|
||||||
const handleSetOption = useCallback(
|
const handleSetOption = useCallback(
|
||||||
|
@ -5,10 +5,11 @@ import Heading from '../components/Heading.jsx';
|
|||||||
import Switch from '../components/Switch.jsx';
|
import Switch from '../components/Switch.jsx';
|
||||||
import { useResizeObserver } from '../hooks';
|
import { useResizeObserver } from '../hooks';
|
||||||
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useApiHost, useConfig } from '../api';
|
import { useApiHost } from '../api';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
export default function CameraMasks({ camera, url }) {
|
export default function CameraMasks({ camera }) {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const imageRef = useRef(null);
|
const imageRef = useRef(null);
|
||||||
const [snap, setSnap] = useState(true);
|
const [snap, setSnap] = useState(true);
|
||||||
@ -20,10 +21,7 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
zones,
|
zones,
|
||||||
} = cameraConfig;
|
} = cameraConfig;
|
||||||
|
|
||||||
const {
|
const { width, height } = cameraConfig.detect;
|
||||||
width,
|
|
||||||
height,
|
|
||||||
} = cameraConfig.detect;
|
|
||||||
|
|
||||||
const [{ width: scaledWidth }] = useResizeObserver(imageRef);
|
const [{ width: scaledWidth }] = useResizeObserver(imageRef);
|
||||||
const imageScale = scaledWidth / width;
|
const imageScale = scaledWidth / width;
|
||||||
@ -100,7 +98,7 @@ export default function CameraMasks({ camera, url }) {
|
|||||||
const handleCopyMotionMasks = useCallback(async () => {
|
const handleCopyMotionMasks = useCallback(async () => {
|
||||||
await window.navigator.clipboard.writeText(` motion:
|
await window.navigator.clipboard.writeText(` motion:
|
||||||
mask:
|
mask:
|
||||||
${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
|
${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
|
||||||
}, [motionMaskPoints]);
|
}, [motionMaskPoints]);
|
||||||
|
|
||||||
// Zone methods
|
// Zone methods
|
||||||
@ -273,16 +271,16 @@ ${Object.keys(objectMaskPoints)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskYamlKeyPrefix(points) {
|
function maskYamlKeyPrefix() {
|
||||||
return ' - ';
|
return ' - ';
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoneYamlKeyPrefix(points, key) {
|
function zoneYamlKeyPrefix(_points, key) {
|
||||||
return ` ${key}:
|
return ` ${key}:
|
||||||
coordinates: `;
|
coordinates: `;
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectYamlKeyPrefix(points, key, subkey) {
|
function objectYamlKeyPrefix() {
|
||||||
return ' - ';
|
return ' - ';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,6 +362,7 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
|
|||||||
? null
|
? null
|
||||||
: scaledPoints.map(([x, y], i) => (
|
: scaledPoints.map(([x, y], i) => (
|
||||||
<PolyPoint
|
<PolyPoint
|
||||||
|
key={i}
|
||||||
boundingRef={boundingRef}
|
boundingRef={boundingRef}
|
||||||
index={i}
|
index={i}
|
||||||
onMove={handleMovePoint}
|
onMove={handleMovePoint}
|
||||||
@ -466,6 +465,7 @@ function MaskValues({
|
|||||||
) : null}
|
) : null}
|
||||||
{points[mainkey].map((item, subkey) => (
|
{points[mainkey].map((item, subkey) => (
|
||||||
<Item
|
<Item
|
||||||
|
key={subkey}
|
||||||
mainkey={mainkey}
|
mainkey={mainkey}
|
||||||
subkey={subkey}
|
subkey={subkey}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@ -481,6 +481,7 @@ function MaskValues({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
|
key={mainkey}
|
||||||
mainkey={mainkey}
|
mainkey={mainkey}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleAdd={onAdd ? handleAdd : undefined}
|
handleAdd={onAdd ? handleAdd : undefined}
|
||||||
@ -497,7 +498,7 @@ function MaskValues({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, handleAdd, handleRemove, yamlKeyPrefix }) {
|
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, _handleAdd, handleRemove, yamlKeyPrefix }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-key={mainkey}
|
data-key={mainkey}
|
||||||
|
@ -2,14 +2,14 @@ import { h, Fragment } from 'preact';
|
|||||||
import JSMpegPlayer from '../components/JSMpegPlayer';
|
import JSMpegPlayer from '../components/JSMpegPlayer';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { useConfig } from '../api';
|
import useSWR from 'swr';
|
||||||
import { Tabs, TextTab } from '../components/Tabs';
|
import { Tabs, TextTab } from '../components/Tabs';
|
||||||
import { LiveChip } from '../components/LiveChip';
|
import { LiveChip } from '../components/LiveChip';
|
||||||
import { DebugCamera } from '../components/DebugCamera';
|
import { DebugCamera } from '../components/DebugCamera';
|
||||||
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
|
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
|
||||||
|
|
||||||
export default function Camera({ camera }) {
|
export default function Camera({ camera }) {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
const [playerType, setPlayerType] = useState('live');
|
const [playerType, setPlayerType] = useState('live');
|
||||||
|
|
||||||
|
@ -6,30 +6,33 @@ import ClipIcon from '../icons/Clip';
|
|||||||
import MotionIcon from '../icons/Motion';
|
import MotionIcon from '../icons/Motion';
|
||||||
import SnapshotIcon from '../icons/Snapshot';
|
import SnapshotIcon from '../icons/Snapshot';
|
||||||
import { useDetectState, useRecordingsState, useSnapshotsState } from '../api/mqtt';
|
import { useDetectState, useRecordingsState, useSnapshotsState } from '../api/mqtt';
|
||||||
import { useConfig, FetchStatus } from '../api';
|
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
export default function Cameras() {
|
export default function Cameras() {
|
||||||
const { data: config, status } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
return status !== FetchStatus.LOADED ? (
|
return !config ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-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 key={camera} name={camera} conf={conf} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Camera({ name, conf }) {
|
function Camera({ name }) {
|
||||||
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
||||||
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
|
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
|
||||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||||
const href = `/cameras/${name}`;
|
const href = `/cameras/${name}`;
|
||||||
const buttons = useMemo(() => {
|
const buttons = useMemo(() => {
|
||||||
return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }];
|
return [
|
||||||
|
{ name: 'Events', href: `/events?camera=${name}` },
|
||||||
|
{ name: 'Recordings', href: `/recording/${name}` },
|
||||||
|
];
|
||||||
}, [name]);
|
}, [name]);
|
||||||
const icons = useMemo(
|
const icons = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
@ -4,21 +4,21 @@ import Button from '../components/Button';
|
|||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import { useMqtt } from '../api/mqtt';
|
import { useMqtt } from '../api/mqtt';
|
||||||
import { useConfig, useStats } from '../api';
|
import useSWR from 'swr';
|
||||||
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
|
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
|
||||||
import { useCallback } from 'preact/hooks';
|
import { useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
const emptyObject = Object.freeze({});
|
const emptyObject = Object.freeze({});
|
||||||
|
|
||||||
export default function Debug() {
|
export default function Debug() {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: { payload: stats },
|
value: { payload: stats },
|
||||||
} = useMqtt('stats');
|
} = useMqtt('stats');
|
||||||
const { data: initialStats } = useStats();
|
const { data: initialStats } = useSWR('stats');
|
||||||
|
|
||||||
const { detectors, service = {}, detection_fps, ...cameras } = stats || initialStats || emptyObject;
|
const { detectors, service = {}, ...cameras } = stats || initialStats || emptyObject;
|
||||||
|
|
||||||
const detectorNames = Object.keys(detectors || emptyObject);
|
const detectorNames = Object.keys(detectors || emptyObject);
|
||||||
const detectorDataKeys = Object.keys(detectors ? detectors[detectorNames[0]] : emptyObject);
|
const detectorDataKeys = Object.keys(detectors ? detectors[detectorNames[0]] : emptyObject);
|
||||||
@ -50,13 +50,13 @@ export default function Debug() {
|
|||||||
<Tr>
|
<Tr>
|
||||||
<Th>detector</Th>
|
<Th>detector</Th>
|
||||||
{detectorDataKeys.map((name) => (
|
{detectorDataKeys.map((name) => (
|
||||||
<Th>{name.replace('_', ' ')}</Th>
|
<Th key={name}>{name.replace('_', ' ')}</Th>
|
||||||
))}
|
))}
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{detectorNames.map((detector, i) => (
|
{detectorNames.map((detector, i) => (
|
||||||
<Tr index={i}>
|
<Tr key={i} index={i}>
|
||||||
<Td>{detector}</Td>
|
<Td>{detector}</Td>
|
||||||
{detectorDataKeys.map((name) => (
|
{detectorDataKeys.map((name) => (
|
||||||
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
|
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
|
||||||
@ -73,13 +73,13 @@ export default function Debug() {
|
|||||||
<Tr>
|
<Tr>
|
||||||
<Th>camera</Th>
|
<Th>camera</Th>
|
||||||
{cameraDataKeys.map((name) => (
|
{cameraDataKeys.map((name) => (
|
||||||
<Th>{name.replace('_', ' ')}</Th>
|
<Th key={name}>{name.replace('_', ' ')}</Th>
|
||||||
))}
|
))}
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{cameraNames.map((camera, i) => (
|
{cameraNames.map((camera, i) => (
|
||||||
<Tr index={i}>
|
<Tr key={i} index={i}>
|
||||||
<Td>
|
<Td>
|
||||||
<Link href={`/cameras/${camera}`}>{camera}</Link>
|
<Link href={`/cameras/${camera}`}>{camera}</Link>
|
||||||
</Td>
|
</Td>
|
||||||
|
@ -1,241 +0,0 @@
|
|||||||
import { h, Fragment } from 'preact';
|
|
||||||
import { useCallback, useState, useEffect } from 'preact/hooks';
|
|
||||||
import Link from '../components/Link';
|
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
|
||||||
import Button from '../components/Button';
|
|
||||||
import ArrowDown from '../icons/ArrowDropdown';
|
|
||||||
import ArrowDropup from '../icons/ArrowDropup';
|
|
||||||
import Clip from '../icons/Clip';
|
|
||||||
import Close from '../icons/Close';
|
|
||||||
import StarRecording from '../icons/StarRecording';
|
|
||||||
import Delete from '../icons/Delete';
|
|
||||||
import Snapshot from '../icons/Snapshot';
|
|
||||||
import Heading from '../components/Heading';
|
|
||||||
import VideoPlayer from '../components/VideoPlayer';
|
|
||||||
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
|
||||||
import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api';
|
|
||||||
import Prompt from '../components/Prompt';
|
|
||||||
|
|
||||||
const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
|
|
||||||
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
|
|
||||||
<Button className="xs:w-auto" color={isRetained ? 'red' : 'yellow'} onClick={handleClickRetain}>
|
|
||||||
<StarRecording className="w-6" />
|
|
||||||
{isRetained ? ('Un-retain event') : ('Retain event')}
|
|
||||||
</Button>
|
|
||||||
<Button className="xs:w-auto" color="red" onClick={handleClickDelete}>
|
|
||||||
<Delete className="w-6" /> Delete event
|
|
||||||
</Button>
|
|
||||||
<Button color="gray" className="xs:w-auto" onClick={() => close()}>
|
|
||||||
<Close className="w-6" /> Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DownloadButtonGroup = ({ className, apiHost, eventId }) => (
|
|
||||||
<span className={`space-y-2 sm:space-y-0 space-x-0 sm:space-x-4 ${className}`}>
|
|
||||||
<Button
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
color="blue"
|
|
||||||
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Clip className="w-6" /> Download Clip
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
color="blue"
|
|
||||||
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
<Snapshot className="w-6" /> Download Snapshot
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function Event({ eventId, close, scrollRef }) {
|
|
||||||
const apiHost = useApiHost();
|
|
||||||
const { data, status } = useEvent(eventId);
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
|
||||||
const [shouldScroll, setShouldScroll] = useState(true);
|
|
||||||
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
|
||||||
const [isRetained, setIsRetained] = useState(false);
|
|
||||||
const setRetainEvent = useRetain();
|
|
||||||
const setDeleteEvent = useDelete();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Scroll event into view when component has been mounted.
|
|
||||||
if (shouldScroll && scrollRef && scrollRef[eventId]) {
|
|
||||||
scrollRef[eventId].scrollIntoView();
|
|
||||||
setShouldScroll(false);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
// When opening new event window, the previous one will sometimes cause the
|
|
||||||
// navbar to be visible, hence the "hide nav" code bellow.
|
|
||||||
// Navbar will be hided if we add the - translate - y - full class.appBar.js
|
|
||||||
const element = document.getElementById('appbar');
|
|
||||||
if (element) element.classList.add('-translate-y-full');
|
|
||||||
};
|
|
||||||
}, [data, scrollRef, eventId, shouldScroll]);
|
|
||||||
|
|
||||||
const handleClickRetain = useCallback(async () => {
|
|
||||||
let success;
|
|
||||||
try {
|
|
||||||
success = await setRetainEvent(eventId, !isRetained);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setIsRetained(!isRetained);
|
|
||||||
|
|
||||||
// Need to reload page otherwise retain button state won't stick if event is collapsed and re-opened.
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}, [eventId, isRetained, setRetainEvent]);
|
|
||||||
|
|
||||||
const handleClickDelete = () => {
|
|
||||||
setShowDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDismissDeleteDialog = () => {
|
|
||||||
setShowDialog(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClickDeleteDialog = useCallback(async () => {
|
|
||||||
let success;
|
|
||||||
try {
|
|
||||||
success = await setDeleteEvent(eventId);
|
|
||||||
setDeleteStatus(success ? FetchStatus.LOADED : FetchStatus.ERROR);
|
|
||||||
} catch (e) {
|
|
||||||
setDeleteStatus(FetchStatus.ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setDeleteStatus(FetchStatus.LOADED);
|
|
||||||
setShowDialog(false);
|
|
||||||
}
|
|
||||||
}, [eventId, setShowDialog, setDeleteEvent]);
|
|
||||||
|
|
||||||
if (status !== FetchStatus.LOADED) {
|
|
||||||
return <ActivityIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsRetained(data.retain_indefinitely);
|
|
||||||
const startime = new Date(data.start_time * 1000);
|
|
||||||
const endtime = data.end_time ? new Date(data.end_time * 1000) : null;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex md:flex-row justify-between flex-wrap flex-col">
|
|
||||||
<div className="space-y-2 xs:space-y-0 sm:space-x-4">
|
|
||||||
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="hidden sm:inline" />
|
|
||||||
<Button className="w-full sm:w-auto" onClick={() => setShowDetails(!showDetails)}>
|
|
||||||
{showDetails ? (
|
|
||||||
<Fragment>
|
|
||||||
<ArrowDropup className="w-6" />
|
|
||||||
Hide event Details
|
|
||||||
</Fragment>
|
|
||||||
) : (
|
|
||||||
<Fragment>
|
|
||||||
<ArrowDown className="w-6" />
|
|
||||||
Show event Details
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
|
|
||||||
{showDialog ? (
|
|
||||||
<Prompt
|
|
||||||
onDismiss={handleDismissDeleteDialog}
|
|
||||||
title="Delete Event?"
|
|
||||||
text={
|
|
||||||
deleteStatus === FetchStatus.ERROR
|
|
||||||
? 'An error occurred, please try again.'
|
|
||||||
: 'This event will be permanently deleted along with any related clips and snapshots'
|
|
||||||
}
|
|
||||||
actions={[
|
|
||||||
deleteStatus !== FetchStatus.LOADING
|
|
||||||
? { text: 'Delete', color: 'red', onClick: handleClickDeleteDialog }
|
|
||||||
: { text: 'Deleting…', color: 'red', disabled: true },
|
|
||||||
{ text: 'Cancel', onClick: handleDismissDeleteDialog },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{showDetails ? (
|
|
||||||
<Table class="w-full">
|
|
||||||
<Thead>
|
|
||||||
<Th>Key</Th>
|
|
||||||
<Th>Value</Th>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
<Tr>
|
|
||||||
<Td>Camera</Td>
|
|
||||||
<Td>
|
|
||||||
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
<Tr index={1}>
|
|
||||||
<Td>Timeframe</Td>
|
|
||||||
<Td>
|
|
||||||
{startime.toLocaleString()}{endtime === null ? ` – ${endtime.toLocaleString()}`:''}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
<Tr>
|
|
||||||
<Td>Score</Td>
|
|
||||||
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
|
|
||||||
</Tr>
|
|
||||||
<Tr index={1}>
|
|
||||||
<Td>Zones</Td>
|
|
||||||
<Td>{data.zones.join(', ')}</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="outer-max-width xs:m-auto">
|
|
||||||
<div className="pt-5 relative pb-20 w-screen xs:w-full">
|
|
||||||
{data.has_clip ? (
|
|
||||||
<Fragment>
|
|
||||||
<Heading size="lg">Clip</Heading>
|
|
||||||
<VideoPlayer
|
|
||||||
options={{
|
|
||||||
preload: 'none',
|
|
||||||
sources: [
|
|
||||||
{
|
|
||||||
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
|
|
||||||
type: 'application/vnd.apple.mpegurl',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
poster: data.has_snapshot
|
|
||||||
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
|
|
||||||
: `data:image/jpeg;base64,${data.thumbnail}`,
|
|
||||||
}}
|
|
||||||
seekOptions={{ forward: 10, back: 5 }}
|
|
||||||
onReady={() => {}}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
) : (
|
|
||||||
<Fragment>
|
|
||||||
<Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
data.has_snapshot
|
|
||||||
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
|
|
||||||
: `data:image/jpeg;base64,${data.thumbnail}`
|
|
||||||
}
|
|
||||||
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 xs:space-y-0">
|
|
||||||
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="block sm:hidden" />
|
|
||||||
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="block sm:hidden" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
375
web/src/routes/Events.jsx
Normal file
375
web/src/routes/Events.jsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { route } from 'preact-router';
|
||||||
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
|
import Heading from '../components/Heading';
|
||||||
|
import { useApiHost } from '../api';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useState, useRef, useCallback, useMemo } from 'preact/hooks';
|
||||||
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
|
import { StarRecording } from '../icons/StarRecording';
|
||||||
|
import { Snapshot } from '../icons/Snapshot';
|
||||||
|
import { Clip } from '../icons/Clip';
|
||||||
|
import { Zone } from '../icons/Zone';
|
||||||
|
import { Camera } from '../icons/Camera';
|
||||||
|
import { Delete } from '../icons/Delete';
|
||||||
|
import { Download } from '../icons/Download';
|
||||||
|
import Menu, { MenuItem } from '../components/Menu';
|
||||||
|
import CalendarIcon from '../icons/Calendar';
|
||||||
|
import Calendar from '../components/Calendar';
|
||||||
|
|
||||||
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
|
const daysAgo = (num) => {
|
||||||
|
let date = new Date();
|
||||||
|
date.setDate(date.getDate() - num);
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthsAgo = (num) => {
|
||||||
|
let date = new Date();
|
||||||
|
date.setMonth(date.getMonth() - num);
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Events({ path, ...props }) {
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
const [searchParams, setSearchParams] = useState({
|
||||||
|
before: null,
|
||||||
|
after: null,
|
||||||
|
camera: props.camera ?? 'all',
|
||||||
|
label: props.label ?? 'all',
|
||||||
|
zone: props.zone ?? 'all',
|
||||||
|
});
|
||||||
|
const [viewEvent, setViewEvent] = useState();
|
||||||
|
const [downloadEvent, setDownloadEvent] = useState({ id: null, has_clip: false, has_snapshot: false });
|
||||||
|
const [showDownloadMenu, setShowDownloadMenu] = useState();
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState();
|
||||||
|
const [showCalendar, setShowCalendar] = useState();
|
||||||
|
|
||||||
|
const eventsFetcher = (path, params) => {
|
||||||
|
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
|
||||||
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKey = (index, prevData) => {
|
||||||
|
if (index > 0) {
|
||||||
|
const lastDate = prevData[prevData.length - 1].start_time;
|
||||||
|
const pagedParams = { ...searchParams, before: lastDate };
|
||||||
|
return ['events', pagedParams];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['events', searchParams];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
|
||||||
|
|
||||||
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
|
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
|
||||||
|
|
||||||
|
const zones = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(config.cameras)
|
||||||
|
.reduce((memo, camera) => {
|
||||||
|
memo = memo.concat(Object.keys(camera.zones));
|
||||||
|
return memo;
|
||||||
|
}, [])
|
||||||
|
.filter((value, i, self) => self.indexOf(value) === i),
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
|
||||||
|
const labels = useMemo(() => {
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.reduce((memo, camera) => {
|
||||||
|
memo = memo.concat(camera.objects?.track || []);
|
||||||
|
return memo;
|
||||||
|
}, config.objects?.track || [])
|
||||||
|
.filter((value, i, self) => self.indexOf(value) === i);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const onSave = async (e, eventId, save) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let response;
|
||||||
|
if (save) {
|
||||||
|
response = await axios.post(`events/${eventId}/retain`);
|
||||||
|
} else {
|
||||||
|
response = await axios.delete(`events/${eventId}/retain`);
|
||||||
|
}
|
||||||
|
if (response.status === 200) {
|
||||||
|
mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (e, eventId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const response = await axios.delete(`events/${eventId}`);
|
||||||
|
if (response.status === 200) {
|
||||||
|
mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const datePicker = useRef();
|
||||||
|
|
||||||
|
const downloadButton = useRef();
|
||||||
|
|
||||||
|
const onDownloadClick = (e, event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDownloadEvent((_prev) => ({ id: event.id, has_clip: event.has_clip, has_snapshot: event.has_snapshot }));
|
||||||
|
downloadButton.current = e.target;
|
||||||
|
setShowDownloadMenu(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectDateRange = useCallback(
|
||||||
|
(dates) => {
|
||||||
|
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
|
||||||
|
setShowDatePicker(false);
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams, setShowDatePicker]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFilter = useCallback(
|
||||||
|
(name, value) => {
|
||||||
|
const updatedParams = { ...searchParams, [name]: value };
|
||||||
|
setSearchParams(updatedParams);
|
||||||
|
const queryString = Object.keys(updatedParams)
|
||||||
|
.map((key) => {
|
||||||
|
if (updatedParams[key] && updatedParams[key] != 'all') {
|
||||||
|
return `${key}=${updatedParams[key]}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((val) => val)
|
||||||
|
.join('&');
|
||||||
|
route(`${path}?${queryString}`);
|
||||||
|
},
|
||||||
|
[path, searchParams, setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT;
|
||||||
|
|
||||||
|
// hooks for infinite scroll
|
||||||
|
const observer = useRef();
|
||||||
|
const lastEventRef = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (isValidating) return;
|
||||||
|
if (observer.current) observer.current.disconnect();
|
||||||
|
observer.current = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isDone) {
|
||||||
|
setSize(size + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (node) observer.current.observe(node);
|
||||||
|
},
|
||||||
|
[size, setSize, isValidating, isDone]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!eventPages || !config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-2 px-4 w-full">
|
||||||
|
<Heading>Events</Heading>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<select
|
||||||
|
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
value={searchParams.camera}
|
||||||
|
onChange={(e) => onFilter('camera', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">all</option>
|
||||||
|
{cameras.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
value={searchParams.label}
|
||||||
|
onChange={(e) => onFilter('label', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">all</option>
|
||||||
|
{labels.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
|
||||||
|
value={searchParams.zone}
|
||||||
|
onChange={(e) => onFilter('zone', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">all</option>
|
||||||
|
{zones.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div ref={datePicker} className="ml-auto">
|
||||||
|
<CalendarIcon className="h-8 w-8 cursor-pointer" onClick={() => setShowDatePicker(true)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showDownloadMenu && (
|
||||||
|
<Menu onDismiss={() => setShowDownloadMenu(false)} relativeTo={downloadButton}>
|
||||||
|
{downloadEvent.has_snapshot && (
|
||||||
|
<MenuItem
|
||||||
|
icon={Snapshot}
|
||||||
|
label="Download Snapshot"
|
||||||
|
value="snapshot"
|
||||||
|
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
|
||||||
|
download
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{downloadEvent.has_clip && (
|
||||||
|
<MenuItem
|
||||||
|
icon={Clip}
|
||||||
|
label="Download Clip"
|
||||||
|
value="clip"
|
||||||
|
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
|
||||||
|
download
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
{showDatePicker && (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={() => setShowDatePicker(false)} relativeTo={datePicker}>
|
||||||
|
<MenuItem label="All" value={{ before: null, after: null }} onSelect={handleSelectDateRange} />
|
||||||
|
<MenuItem label="Today" value={{ before: null, after: daysAgo(0) }} onSelect={handleSelectDateRange} />
|
||||||
|
<MenuItem
|
||||||
|
label="Yesterday"
|
||||||
|
value={{ before: daysAgo(0), after: daysAgo(1) }}
|
||||||
|
onSelect={handleSelectDateRange}
|
||||||
|
/>
|
||||||
|
<MenuItem label="Last 7 Days" value={{ before: null, after: daysAgo(7) }} onSelect={handleSelectDateRange} />
|
||||||
|
<MenuItem label="This Month" value={{ before: null, after: monthsAgo(0) }} onSelect={handleSelectDateRange} />
|
||||||
|
<MenuItem
|
||||||
|
label="Last Month"
|
||||||
|
value={{ before: monthsAgo(0), after: monthsAgo(1) }}
|
||||||
|
onSelect={handleSelectDateRange}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Custom Range"
|
||||||
|
value="custom"
|
||||||
|
onSelect={() => {
|
||||||
|
setShowCalendar(true);
|
||||||
|
setShowDatePicker(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
{showCalendar && (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={() => setShowCalendar(false)} relativeTo={datePicker}>
|
||||||
|
<Calendar onChange={handleSelectDateRange} close={() => setShowCalendar(false)} />
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{eventPages.map((page, i) => {
|
||||||
|
const lastPage = eventPages.length === i + 1;
|
||||||
|
return page.map((event, j) => {
|
||||||
|
const lastEvent = lastPage && page.length === j + 1;
|
||||||
|
return (
|
||||||
|
<Fragment key={event.id}>
|
||||||
|
<div
|
||||||
|
ref={lastEvent ? lastEventRef : false}
|
||||||
|
className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
|
||||||
|
onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain"
|
||||||
|
style={{
|
||||||
|
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StarRecording
|
||||||
|
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||||
|
onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
|
||||||
|
fill={event.retain_indefinitely ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
{event.end_time ? null : (
|
||||||
|
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||||
|
In progress
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="m-2 flex grow">
|
||||||
|
<div className="flex flex-col grow">
|
||||||
|
<div className="capitalize text-lg font-bold">
|
||||||
|
{event.label} ({(event.top_score * 100).toFixed(0)}%)
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{new Date(event.start_time * 1000).toLocaleDateString()}{' '}
|
||||||
|
{new Date(event.start_time * 1000).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
<div className="capitalize text-sm flex align-center mt-1">
|
||||||
|
<Camera className="h-5 w-5 mr-2 inline" />
|
||||||
|
{event.camera}
|
||||||
|
</div>
|
||||||
|
<div className="capitalize text-sm flex align-center">
|
||||||
|
<Zone className="w-5 h-5 mr-2 inline" />
|
||||||
|
{event.zones.join(',')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<Delete className="cursor-pointer" stroke="#f87171" onClick={(e) => onDelete(e, event.id)} />
|
||||||
|
|
||||||
|
<Download
|
||||||
|
className="h-6 w-6 mt-auto"
|
||||||
|
stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
|
||||||
|
onClick={(e) => onDownloadClick(e, event)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{viewEvent !== event.id ? null : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="mx-auto">
|
||||||
|
{event.has_clip ? (
|
||||||
|
<>
|
||||||
|
<Heading size="lg">Clip</Heading>
|
||||||
|
<VideoPlayer
|
||||||
|
options={{
|
||||||
|
preload: 'auto',
|
||||||
|
autoplay: true,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
src: `${apiHost}/vod/event/${event.id}/index.m3u8`,
|
||||||
|
type: 'application/vnd.apple.mpegurl',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
seekOptions={{ forward: 10, back: 5 }}
|
||||||
|
onReady={() => {}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div>
|
||||||
|
<Heading size="sm">{event.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
|
||||||
|
<img
|
||||||
|
className="flex-grow-0"
|
||||||
|
src={
|
||||||
|
event.has_snapshot
|
||||||
|
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
|
||||||
|
: `data:image/jpeg;base64,${event.thumbnail}`
|
||||||
|
}
|
||||||
|
alt={`${event.label} at ${(event.top_score * 100).toFixed(0)}% confidence`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>{isDone ? null : <ActivityIndicator />}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,26 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import Select from '../../../components/Select';
|
|
||||||
import { useCallback } from 'preact/hooks';
|
|
||||||
|
|
||||||
function Filter({ onChange, searchParams, paramName, options, ...rest }) {
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(key) => {
|
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
|
||||||
Object.keys(key).map((entries) => {
|
|
||||||
if (key[entries] !== 'all') {
|
|
||||||
newParams.set(entries, key[entries]);
|
|
||||||
} else {
|
|
||||||
paramName.map((p) => newParams.delete(p));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onChange(newParams);
|
|
||||||
},
|
|
||||||
[searchParams, paramName, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const obj = {};
|
|
||||||
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
|
|
||||||
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
|
|
||||||
}
|
|
||||||
export default Filter;
|
|
@ -1,38 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import { useCallback, useMemo } from 'preact/hooks';
|
|
||||||
import Link from '../../../components/Link';
|
|
||||||
import { route } from 'preact-router';
|
|
||||||
|
|
||||||
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
|
||||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
|
||||||
searchParams.delete('limit');
|
|
||||||
searchParams.delete('include_thumbnails');
|
|
||||||
// searchParams.delete('before');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const href = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set(paramName, name);
|
|
||||||
removeDefaultSearchKeys(params);
|
|
||||||
return `${pathname}?${params.toString()}`;
|
|
||||||
}, [searchParams, paramName, pathname, name, removeDefaultSearchKeys]);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
route(href, true);
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set(paramName, name);
|
|
||||||
onFilter(params);
|
|
||||||
},
|
|
||||||
[href, searchParams, onFilter, paramName, name]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={href} onclick={handleClick}>
|
|
||||||
{name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Filterable;
|
|
@ -1,81 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import Filter from './filter';
|
|
||||||
import { useConfig } from '../../../api';
|
|
||||||
import { useMemo, useState } from 'preact/hooks';
|
|
||||||
import { DateFilterOptions } from '../../../components/DatePicker';
|
|
||||||
import Button from '../../../components/Button';
|
|
||||||
|
|
||||||
const Filters = ({ onChange, searchParams }) => {
|
|
||||||
const [viewFilters, setViewFilters] = useState(false);
|
|
||||||
const { data } = useConfig();
|
|
||||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
|
||||||
|
|
||||||
const zones = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(data.cameras)
|
|
||||||
.reduce((memo, camera) => {
|
|
||||||
memo = memo.concat(Object.keys(camera.zones));
|
|
||||||
return memo;
|
|
||||||
}, [])
|
|
||||||
.filter((value, i, self) => self.indexOf(value) === i),
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const labels = useMemo(() => {
|
|
||||||
return Object.values(data.cameras)
|
|
||||||
.reduce((memo, camera) => {
|
|
||||||
memo = memo.concat(camera.objects?.track || []);
|
|
||||||
return memo;
|
|
||||||
}, data.objects?.track || [])
|
|
||||||
.filter((value, i, self) => self.indexOf(value) === i);
|
|
||||||
}, [data]);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setViewFilters(!viewFilters)}
|
|
||||||
className="block xs:hidden w-full mb-4 text-center"
|
|
||||||
type="text"
|
|
||||||
>
|
|
||||||
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
|
|
||||||
</Button>
|
|
||||||
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
|
|
||||||
<Filter
|
|
||||||
type="dropdown"
|
|
||||||
onChange={onChange}
|
|
||||||
options={['all', ...cameras]}
|
|
||||||
paramName={['camera']}
|
|
||||||
label="Camera"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Filter
|
|
||||||
type="dropdown"
|
|
||||||
onChange={onChange}
|
|
||||||
options={['all', ...zones]}
|
|
||||||
paramName={['zone']}
|
|
||||||
label="Zone"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Filter
|
|
||||||
type="dropdown"
|
|
||||||
onChange={onChange}
|
|
||||||
options={['all', ...labels]}
|
|
||||||
paramName={['label']}
|
|
||||||
label="Label"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Filter
|
|
||||||
type="datepicker"
|
|
||||||
onChange={onChange}
|
|
||||||
options={DateFilterOptions}
|
|
||||||
paramName={['before', 'after']}
|
|
||||||
label="DatePicker"
|
|
||||||
searchParams={searchParams}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Filters;
|
|
@ -1,3 +0,0 @@
|
|||||||
export { default as TableHead } from './tableHead';
|
|
||||||
export { default as TableRow } from './tableRow';
|
|
||||||
export { default as Filters } from './filters';
|
|
@ -1,19 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import { Thead, Th, Tr } from '../../../components/Table';
|
|
||||||
|
|
||||||
const TableHead = () => (
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th />
|
|
||||||
<Th>Camera</Th>
|
|
||||||
<Th>Label</Th>
|
|
||||||
<Th>Score</Th>
|
|
||||||
<Th>Zones</Th>
|
|
||||||
<Th>Retain</Th>
|
|
||||||
<Th>Date</Th>
|
|
||||||
<Th>Start</Th>
|
|
||||||
<Th>End</Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
);
|
|
||||||
export default TableHead;
|
|
@ -1,121 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import { memo } from 'preact/compat';
|
|
||||||
import { useCallback, useState, useMemo } from 'preact/hooks';
|
|
||||||
import { Tr, Td, Tbody } from '../../../components/Table';
|
|
||||||
import Filterable from './filterable';
|
|
||||||
import Event from '../../Event';
|
|
||||||
import { useSearchString } from '../../../hooks/useSearchString';
|
|
||||||
import { useClickOutside } from '../../../hooks/useClickOutside';
|
|
||||||
|
|
||||||
const EventsRow = memo(
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
apiHost,
|
|
||||||
start_time: startTime,
|
|
||||||
end_time: endTime,
|
|
||||||
scrollToRef,
|
|
||||||
lastRowRef,
|
|
||||||
handleFilter,
|
|
||||||
pathname,
|
|
||||||
limit,
|
|
||||||
camera,
|
|
||||||
label,
|
|
||||||
top_score: score,
|
|
||||||
zones,
|
|
||||||
retain_indefinitely
|
|
||||||
}) => {
|
|
||||||
const [viewEvent, setViewEvent] = useState(null);
|
|
||||||
const { searchString, removeDefaultSearchKeys } = useSearchString(limit);
|
|
||||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
|
||||||
|
|
||||||
const innerRef = useClickOutside(() => {
|
|
||||||
setViewEvent(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewEventHandler = useCallback(
|
|
||||||
(id) => {
|
|
||||||
//Toggle event view
|
|
||||||
if (viewEvent === id) return setViewEvent(null);
|
|
||||||
//Set event id to be rendered.
|
|
||||||
setViewEvent(id);
|
|
||||||
},
|
|
||||||
[viewEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const start = new Date(parseInt(startTime * 1000, 10));
|
|
||||||
const end = endTime ? new Date(parseInt(endTime * 1000, 10)) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tbody reference={innerRef}>
|
|
||||||
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
|
|
||||||
<Td className="w-40">
|
|
||||||
<a
|
|
||||||
onClick={() => viewEventHandler(id)}
|
|
||||||
ref={lastRowRef}
|
|
||||||
data-start-time={startTime}
|
|
||||||
// data-reached-end={reachedEnd} <-- Enable this will cause all events to re-render when reaching end.
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
width="150"
|
|
||||||
height="150"
|
|
||||||
className="cursor-pointer"
|
|
||||||
style="min-height: 48px; min-width: 48px;"
|
|
||||||
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Filterable
|
|
||||||
onFilter={handleFilter}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchParams}
|
|
||||||
paramName="camera"
|
|
||||||
name={camera}
|
|
||||||
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Filterable
|
|
||||||
onFilter={handleFilter}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchParams}
|
|
||||||
paramName="label"
|
|
||||||
name={label}
|
|
||||||
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>{(score * 100).toFixed(2)}%</Td>
|
|
||||||
<Td>
|
|
||||||
<ul>
|
|
||||||
{zones.map((zone) => (
|
|
||||||
<li>
|
|
||||||
<Filterable
|
|
||||||
onFilter={handleFilter}
|
|
||||||
pathname={pathname}
|
|
||||||
searchParams={searchString}
|
|
||||||
paramName="zone"
|
|
||||||
name={zone}
|
|
||||||
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Td>
|
|
||||||
<Td>{retain_indefinitely ? 'True' : 'False'}</Td>
|
|
||||||
<Td>{start.toLocaleDateString()}</Td>
|
|
||||||
<Td>{start.toLocaleTimeString()}</Td>
|
|
||||||
<Td>{end === null ? 'In progress' : end.toLocaleTimeString()}</Td>
|
|
||||||
</Tr>
|
|
||||||
{viewEvent === id ? (
|
|
||||||
<Tr className="border-b-1">
|
|
||||||
<Td colSpan="8" reference={(el) => (scrollToRef[id] = el)}>
|
|
||||||
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
) : null}
|
|
||||||
</Tbody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default EventsRow;
|
|
@ -1,107 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import ActivityIndicator from '../../components/ActivityIndicator';
|
|
||||||
import Heading from '../../components/Heading';
|
|
||||||
import { TableHead, Filters, TableRow } from './components';
|
|
||||||
import { route } from 'preact-router';
|
|
||||||
import { FetchStatus, useApiHost, useEvents } from '../../api';
|
|
||||||
import { Table, Tfoot, Tr, Td } from '../../components/Table';
|
|
||||||
import { useCallback, useEffect, useMemo, useReducer } from 'preact/hooks';
|
|
||||||
import { reducer, initialState } from './reducer';
|
|
||||||
import { useSearchString } from '../../hooks/useSearchString';
|
|
||||||
import { useIntersectionObserver } from '../../hooks';
|
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
|
||||||
|
|
||||||
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
|
||||||
const apiHost = useApiHost();
|
|
||||||
const { searchString, setSearchString, removeDefaultSearchKeys } = useSearchString(limit);
|
|
||||||
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
|
|
||||||
const { data, status, deletedId } = useEvents(searchString);
|
|
||||||
|
|
||||||
const scrollToRef = useMemo(() => Object, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data && !(searchString in searchStrings)) {
|
|
||||||
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
|
||||||
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedId) {
|
|
||||||
dispatch({ type: 'DELETE_EVENT', deletedId });
|
|
||||||
}
|
|
||||||
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
|
|
||||||
|
|
||||||
const [entry, setIntersectNode] = useIntersectionObserver();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (entry && entry.isIntersecting) {
|
|
||||||
const { startTime } = entry.target.dataset;
|
|
||||||
const { searchParams } = new URL(window.location);
|
|
||||||
searchParams.set('before', parseFloat(startTime) - 0.0001);
|
|
||||||
setSearchString(limit, searchParams.toString());
|
|
||||||
}
|
|
||||||
}, [entry, limit, setSearchString]);
|
|
||||||
|
|
||||||
const lastCellRef = useCallback(
|
|
||||||
(node) => {
|
|
||||||
if (node !== null && !reachedEnd) {
|
|
||||||
setIntersectNode(node);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setIntersectNode, reachedEnd]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFilter = useCallback(
|
|
||||||
(searchParams) => {
|
|
||||||
dispatch({ type: 'RESET' });
|
|
||||||
removeDefaultSearchKeys(searchParams);
|
|
||||||
setSearchString(limit, searchParams.toString());
|
|
||||||
route(`${pathname}?${searchParams.toString()}`);
|
|
||||||
},
|
|
||||||
[limit, pathname, setSearchString, removeDefaultSearchKeys]
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
|
||||||
|
|
||||||
const RenderTableRow = useCallback(
|
|
||||||
(props) => (
|
|
||||||
<TableRow
|
|
||||||
key={props.id}
|
|
||||||
apiHost={apiHost}
|
|
||||||
scrollToRef={scrollToRef}
|
|
||||||
pathname={pathname}
|
|
||||||
limit={API_LIMIT}
|
|
||||||
handleFilter={handleFilter}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[apiHost, handleFilter, pathname, scrollToRef]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 p-2 px-4 w-full">
|
|
||||||
<Heading>Events</Heading>
|
|
||||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
|
||||||
<div className="min-w-0 overflow-auto">
|
|
||||||
<Table className="min-w-full table-fixed">
|
|
||||||
<TableHead />
|
|
||||||
|
|
||||||
{events.map((props, idx) => {
|
|
||||||
const lastRowRef = idx === events.length - 1 ? lastCellRef : undefined;
|
|
||||||
return <RenderTableRow {...props} lastRowRef={lastRowRef} idx={idx} />;
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Tfoot>
|
|
||||||
<Tr>
|
|
||||||
<Td className="text-center p-4" colSpan="8">
|
|
||||||
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tfoot>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
import produce from 'immer';
|
|
||||||
|
|
||||||
export const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
|
|
||||||
|
|
||||||
export const reducer = (state = initialState, action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'DELETE_EVENT': {
|
|
||||||
const { deletedId } = action;
|
|
||||||
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
const idx = draftState.events.findIndex((e) => e.id === deletedId);
|
|
||||||
if (idx === -1) return state;
|
|
||||||
|
|
||||||
draftState.events.splice(idx, 1);
|
|
||||||
draftState.deleted++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case 'APPEND_EVENTS': {
|
|
||||||
const {
|
|
||||||
meta: { searchString },
|
|
||||||
payload,
|
|
||||||
} = action;
|
|
||||||
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
draftState.searchStrings[searchString] = true;
|
|
||||||
draftState.events.push(...payload);
|
|
||||||
draftState.deleted = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'REACHED_END': {
|
|
||||||
const {
|
|
||||||
meta: { searchString },
|
|
||||||
} = action;
|
|
||||||
return produce(state, (draftState) => {
|
|
||||||
draftState.reachedEnd = true;
|
|
||||||
draftState.searchStrings[searchString] = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'RESET':
|
|
||||||
return initialState;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
@ -4,13 +4,14 @@ import ActivityIndicator from '../components/ActivityIndicator';
|
|||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import RecordingPlaylist from '../components/RecordingPlaylist';
|
import RecordingPlaylist from '../components/RecordingPlaylist';
|
||||||
import VideoPlayer from '../components/VideoPlayer';
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
import { FetchStatus, useApiHost, useRecording } from '../api';
|
import { useApiHost } from '../api';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
export default function Recording({ camera, date, hour, seconds }) {
|
export default function Recording({ camera, date, hour, seconds }) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data, status } = useRecording(camera);
|
const { data } = useSWR(`${camera}/recordings`);
|
||||||
|
|
||||||
if (status !== FetchStatus.LOADED) {
|
if (!data) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,68 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import * as Api from '../../api';
|
|
||||||
import Event from '../Event';
|
|
||||||
import { render, screen } from '@testing-library/preact';
|
|
||||||
|
|
||||||
describe('Event Route', () => {
|
|
||||||
let useEventMock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
useEventMock = jest.spyOn(Api, 'useEvent').mockImplementation(() => ({
|
|
||||||
data: mockEvent,
|
|
||||||
status: 'loaded',
|
|
||||||
}));
|
|
||||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://localhost:5000');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows an ActivityIndicator if not yet loaded', async () => {
|
|
||||||
useEventMock.mockReturnValueOnce(() => ({ status: 'loading' }));
|
|
||||||
render(<Event eventId={mockEvent.id} />);
|
|
||||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows cameras', async () => {
|
|
||||||
render(<Event eventId={mockEvent.id} />);
|
|
||||||
|
|
||||||
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.queryByText('Clip')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText('Video Player')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Best Image')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Thumbnail')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not render a video if there is no clip', async () => {
|
|
||||||
useEventMock.mockReturnValue({ data: { ...mockEvent, has_clip: false }, status: 'loaded' });
|
|
||||||
render(<Event eventId={mockEvent.id} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Clip')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText('Video Player')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Best Image')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Thumbnail')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows the thumbnail if no snapshot available', async () => {
|
|
||||||
useEventMock.mockReturnValue({ data: { ...mockEvent, has_clip: false, has_snapshot: false }, status: 'loaded' });
|
|
||||||
render(<Event eventId={mockEvent.id} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Best Image')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Thumbnail')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByAltText('person at 82.0% confidence')).toHaveAttribute(
|
|
||||||
'src',
|
|
||||||
'data:image/jpeg;base64,/9j/4aa...'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockEvent = {
|
|
||||||
camera: 'front',
|
|
||||||
end_time: 1613257337.841237,
|
|
||||||
has_clip: true,
|
|
||||||
has_snapshot: true,
|
|
||||||
id: '1613257326.237365-83cgl2',
|
|
||||||
label: 'person',
|
|
||||||
start_time: 1613257326.237365,
|
|
||||||
top_score: 0.8203125,
|
|
||||||
zones: ['front_patio'],
|
|
||||||
thumbnail: '/9j/4aa...',
|
|
||||||
};
|
|
@ -1,44 +1,39 @@
|
|||||||
export async function getCameraMap(url, cb, props) {
|
export async function getCameraMap(_url, _cb, _props) {
|
||||||
const module = await import('./CameraMap.jsx');
|
const module = await import('./CameraMap.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCamera(url, cb, props) {
|
export async function getCamera(_url, _cb, _props) {
|
||||||
const module = await import('./Camera.jsx');
|
const module = await import('./Camera.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCameraV2(url, cb, props) {
|
export async function getCameraV2(_url, _cb, _props) {
|
||||||
const module = await import('./Camera_V2.jsx');
|
const module = await import('./Camera_V2.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvent(url, cb, props) {
|
export async function getBirdseye(_url, _cb, _props) {
|
||||||
const module = await import('./Event.jsx');
|
|
||||||
return module.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBirdseye(url, cb, props) {
|
|
||||||
const module = await import('./Birdseye.jsx');
|
const module = await import('./Birdseye.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvents(url, cb, props) {
|
export async function getEvents(_url, _cb, _props) {
|
||||||
const module = await import('./Events');
|
const module = await import('./Events.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecording(url, cb, props) {
|
export async function getRecording(_url, _cb, _props) {
|
||||||
const module = await import('./Recording.jsx');
|
const module = await import('./Recording.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDebug(url, cb, props) {
|
export async function getDebug(_url, _cb, _props) {
|
||||||
const module = await import('./Debug.jsx');
|
const module = await import('./Debug.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStyleGuide(url, cb, props) {
|
export async function getStyleGuide(_url, _cb, _props) {
|
||||||
const module = await import('./StyleGuide.jsx');
|
const module = await import('./StyleGuide.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
import type { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
||||||
import { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
|
import type { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
|
||||||
import { epochToLong, longToDate } from '../dateUtil';
|
import { epochToLong, longToDate } from '../dateUtil';
|
||||||
|
|
||||||
export const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
|
export const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
|
||||||
@ -51,15 +51,15 @@ export const getTimelineEventBlocksFromTimelineEvents = (events: TimelineEvent[]
|
|||||||
return rows;
|
return rows;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
||||||
}
|
};
|
||||||
|
|
||||||
export const findLargestYOffsetInBlocks = (blocks: TimelineEventBlock[]): number => {
|
export const findLargestYOffsetInBlocks = (blocks: TimelineEventBlock[]): number => {
|
||||||
return blocks.reduce((largestYOffset, current) => {
|
return blocks.reduce((largestYOffset, current) => {
|
||||||
if (current.yOffset > largestYOffset) {
|
if (current.yOffset > largestYOffset) {
|
||||||
return current.yOffset
|
return current.yOffset;
|
||||||
}
|
}
|
||||||
return largestYOffset;
|
return largestYOffset;
|
||||||
}, 0)
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset: number): number => {
|
export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset: number): number => {
|
||||||
@ -68,6 +68,6 @@ export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset:
|
|||||||
const startTimeEpoch = firstBlock.startTime.getTime();
|
const startTimeEpoch = firstBlock.startTime.getTime();
|
||||||
const endTimeEpoch = Date.now();
|
const endTimeEpoch = Date.now();
|
||||||
const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch);
|
const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch);
|
||||||
return timelineDurationLong + offset * 2
|
return timelineDurationLong + offset * 2;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
@ -5,11 +5,11 @@ export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
|||||||
const getDateTimeYesterday = (dateTime: Date): Date => {
|
const getDateTimeYesterday = (dateTime: Date): Date => {
|
||||||
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||||
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||||
}
|
};
|
||||||
|
|
||||||
const getNowYesterday = (): Date => {
|
const getNowYesterday = (): Date => {
|
||||||
return getDateTimeYesterday(new Date());
|
return getDateTimeYesterday(new Date());
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getNowYesterdayInLong = (): number => {
|
export const getNowYesterdayInLong = (): number => {
|
||||||
return dateToLong(getNowYesterday());
|
return dateToLong(getNowYesterday());
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
import type { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
||||||
|
|
||||||
export const getColorFromTimelineEvent = (event: TimelineEvent) => {
|
export const getColorFromTimelineEvent = (event: TimelineEvent) => {
|
||||||
const { label } = event;
|
const { label } = event;
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
export const convertRemToPixels = (rem: number): number => {
|
export const convertRemToPixels = (rem: number): number => {
|
||||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
}
|
};
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
purge: ['./public/**/*.html', './src/**/*.{jsx,tsx}', './src/utils/tailwind/*.{jsx,tsx,js,ts}'],
|
mode: 'jit',
|
||||||
|
content: [
|
||||||
|
"./public/**/*.html",
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@ -20,8 +24,7 @@ module.exports = {
|
|||||||
none: '',
|
none: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
plugins: [
|
||||||
extend: {},
|
require('@tailwindcss/forms'),
|
||||||
},
|
],
|
||||||
plugins: [],
|
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,28 @@
|
|||||||
{
|
{
|
||||||
|
"include": ["./src/**/*.tsx", "./src/**/*.ts"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "CommonJS",
|
"module": "esnext",
|
||||||
"target": "ES2019",
|
"target": "esnext",
|
||||||
"jsx": "react",
|
"moduleResolution": "node",
|
||||||
|
"jsx": "preserve",
|
||||||
"jsxFactory": "h",
|
"jsxFactory": "h",
|
||||||
"lib": [
|
"baseUrl": "./",
|
||||||
"ES2019"
|
/* paths - import rewriting/resolving */
|
||||||
]
|
"paths": {
|
||||||
},
|
// If you configured any Snowpack aliases, add them here.
|
||||||
"include": [
|
// Add this line to get types for streaming imports (packageOptions.source="remote"):
|
||||||
"./src/**/*.tsx",
|
// "*": [".snowpack/types/*"]
|
||||||
"./src/**/*.ts"
|
// More info: https://www.snowpack.dev/guides/streaming-imports
|
||||||
]
|
},
|
||||||
}
|
/* noEmit - Snowpack builds (emits) files, not tsc. */
|
||||||
|
"noEmit": true,
|
||||||
|
/* Additional Options */
|
||||||
|
"strict": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
// "types": ["mocha", "snowpack-env"],
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"importsNotUsedAsValues": "error"
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user