mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
swr events refactor
This commit is contained in:
parent
4bae3993da
commit
1c9ba11e07
@ -9,19 +9,38 @@
|
||||
"mhutchie.git-graph",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"eamodio.gitlens",
|
||||
"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": {
|
||||
"python.pythonPath": "/usr/bin/python3",
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.formatting.provider": "black",
|
||||
"python.languageServer": "Pylance",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": 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
|
||||
core
|
||||
*.mp4
|
||||
*.jpg
|
||||
*.db
|
||||
*.ts
|
@ -20,6 +20,7 @@ services:
|
||||
- .:/lab/frigate:cached
|
||||
- ./config/config.yml:/config/config.yml:ro
|
||||
- ./debug:/media/frigate
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
ports:
|
||||
- "1935:1935"
|
||||
- "5000:5000"
|
||||
|
@ -6,7 +6,7 @@ ARG USER_GID=$USER_UID
|
||||
|
||||
# Create the user
|
||||
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.
|
||||
&& apt-get update \
|
||||
|
@ -173,7 +173,6 @@ http {
|
||||
location /api/ {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header Cache-Control "no-store";
|
||||
proxy_pass http://frigate_api/;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header Host $host;
|
||||
|
@ -183,8 +183,11 @@ def delete_event(id):
|
||||
def event_thumbnail(id):
|
||||
format = request.args.get("format", "ios")
|
||||
thumbnail_bytes = None
|
||||
event_complete = False
|
||||
try:
|
||||
event = Event.get(Event.id == id)
|
||||
if not event.end_time is None:
|
||||
event_complete = True
|
||||
thumbnail_bytes = base64.b64decode(event.thumbnail)
|
||||
except DoesNotExist:
|
||||
# see if the object is currently being tracked
|
||||
@ -219,6 +222,8 @@ def event_thumbnail(id):
|
||||
|
||||
response = make_response(thumbnail_bytes)
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
if event_complete:
|
||||
response.headers["Cache-Control"] = "private, max-age=31536000"
|
||||
return response
|
||||
|
||||
|
||||
@ -305,9 +310,9 @@ def event_clip(id):
|
||||
@bp.route("/events")
|
||||
def events():
|
||||
limit = request.args.get("limit", 100)
|
||||
camera = request.args.get("camera")
|
||||
label = request.args.get("label")
|
||||
zone = request.args.get("zone")
|
||||
camera = request.args.get("camera", "all")
|
||||
label = request.args.get("label", "all")
|
||||
zone = request.args.get("zone", "all")
|
||||
after = request.args.get("after", type=float)
|
||||
before = request.args.get("before", type=float)
|
||||
has_clip = request.args.get("has_clip", type=int)
|
||||
@ -317,20 +322,20 @@ def events():
|
||||
clauses = []
|
||||
excluded_fields = []
|
||||
|
||||
if camera:
|
||||
if camera != "all":
|
||||
clauses.append((Event.camera == camera))
|
||||
|
||||
if label:
|
||||
if label != "all":
|
||||
clauses.append((Event.label == label))
|
||||
|
||||
if zone:
|
||||
if zone != "all":
|
||||
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
||||
|
||||
if after:
|
||||
clauses.append((Event.start_time >= after))
|
||||
clauses.append((Event.start_time > after))
|
||||
|
||||
if before:
|
||||
clauses.append((Event.start_time <= before))
|
||||
clauses.append((Event.start_time < before))
|
||||
|
||||
if not has_clip is None:
|
||||
clauses.append((Event.has_clip == has_clip))
|
||||
@ -648,7 +653,7 @@ def recording_clip(camera, start_ts, end_ts):
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
"-",
|
||||
"/dev/stdin",
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
|
@ -1,2 +1,3 @@
|
||||
build/*
|
||||
node_modules/*
|
||||
src/env.js
|
162
web/.eslintrc.js
162
web/.eslintrc.js
@ -1,157 +1,39 @@
|
||||
module.exports = {
|
||||
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: {
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
experimentalObjectRestSpread: true,
|
||||
modules: 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: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
react: {
|
||||
pragma: 'h',
|
||||
},
|
||||
},
|
||||
|
||||
globals: {
|
||||
sleep: true,
|
||||
},
|
||||
rules: {
|
||||
indent: ['error', 2, { SwitchCase: 1 }],
|
||||
'comma-dangle': ['error', { objects: 'always-multiline', arrays: 'always-multiline' }],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.{ts,tsx}'],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['plugin:@typescript-eslint/recommended'],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -7,6 +7,6 @@ module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
timers: 'fake',
|
||||
moduleNameMapper: {
|
||||
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js'
|
||||
}
|
||||
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js',
|
||||
},
|
||||
};
|
||||
|
@ -3,6 +3,7 @@
|
||||
"target": "ES2019",
|
||||
"jsx": "preserve",
|
||||
"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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||
"start:custom": "snowpack dev",
|
||||
"start": "SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||
"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:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^5.0.1",
|
||||
"axios": "^0.26.0",
|
||||
"date-fns": "^2.21.3",
|
||||
"idb-keyval": "^5.0.2",
|
||||
"immer": "^9.0.6",
|
||||
"preact": "^10.5.9",
|
||||
"preact": "^10.6.6",
|
||||
"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",
|
||||
"videojs-playlist": "^4.3.1",
|
||||
"videojs-seek-buttons": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.12.13",
|
||||
"@babel/eslint-parser": "^7.17.0",
|
||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||
"@babel/preset-env": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@prefresh/snowpack": "^3.0.1",
|
||||
"@prefresh/snowpack": "^3.1.4",
|
||||
"@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/preact": "^2.0.1",
|
||||
"@testing-library/user-event": "^12.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.12.0",
|
||||
"@typescript-eslint/parser": "^5.12.0",
|
||||
"autoprefixer": "^10.2.1",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.19.0",
|
||||
"eslint-config-preact": "^1.1.3",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jest": "^24.1.3",
|
||||
"eslint-plugin-testing-library": "^3.10.1",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^26.1.1",
|
||||
"eslint-plugin-testing-library": "^5.0.5",
|
||||
"jest": "^26.6.3",
|
||||
"postcss": "^8.2.10",
|
||||
"postcss": "^8.4.7",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"preact-cli": "^3.3.5",
|
||||
"prettier": "^2.2.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"snowpack": "^3.0.11",
|
||||
"snowpack-plugin-hash": "^0.14.2",
|
||||
"tailwindcss": "^2.0.2"
|
||||
"snowpack": "^3.8.8",
|
||||
"snowpack-plugin-hash": "^0.16.0",
|
||||
"tailwindcss": "^3.0.23"
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
@ -1,6 +1,4 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true,
|
||||
useTabs: false,
|
||||
};
|
||||
|
@ -3,7 +3,10 @@ module.exports = {
|
||||
public: { url: '/', static: true },
|
||||
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' }],
|
||||
optimize: {
|
||||
bundle: false,
|
||||
@ -11,9 +14,22 @@ module.exports = {
|
||||
treeshake: true,
|
||||
},
|
||||
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: {
|
||||
sourcemap: false,
|
||||
buildOptions: {},
|
||||
alias: {
|
||||
react: 'preact/compat',
|
||||
'react-dom': 'preact/compat',
|
||||
},
|
||||
};
|
||||
|
@ -7,17 +7,18 @@ import Cameras from './routes/Cameras';
|
||||
import { Router } from 'preact-router';
|
||||
import Sidebar from './Sidebar';
|
||||
import { DarkModeProvider, DrawerProvider } from './context';
|
||||
import { FetchStatus, useConfig } from './api';
|
||||
import useSWR from 'swr';
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<DarkModeProvider>
|
||||
<DrawerProvider>
|
||||
<div data-testid="app" className="w-full">
|
||||
<AppBar />
|
||||
{status !== FetchStatus.LOADED ? (
|
||||
{!config ? (
|
||||
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
|
@ -19,7 +19,7 @@ export default function AppBar() {
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
const handleSelectDarkMode = useCallback(
|
||||
(value, label) => {
|
||||
(value) => {
|
||||
setDarkMode(value);
|
||||
setShowMoreMenu(false);
|
||||
},
|
||||
|
@ -3,12 +3,12 @@ import LinkedLogo from './components/LinkedLogo';
|
||||
import { Match } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { ENV } from './env';
|
||||
import { useConfig } from './api';
|
||||
import useSWR from 'swr';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { data: config } = useConfig();
|
||||
const { data: config } = useSWR('config');
|
||||
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
|
||||
const { birdseye } = config;
|
||||
|
||||
@ -21,7 +21,7 @@ export default function Sidebar() {
|
||||
<Fragment>
|
||||
<Separator />
|
||||
{cameras.map(([camera]) => (
|
||||
<Destination href={`/cameras/${camera}`} text={camera} />
|
||||
<Destination key={camera} href={`/cameras/${camera}`} text={camera} />
|
||||
))}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
|
@ -37,7 +37,7 @@ describe('useFetch', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
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')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
|
@ -8,7 +8,9 @@ function Test() {
|
||||
return state.__connected ? (
|
||||
<div data-testid="data">
|
||||
{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>
|
||||
) : null;
|
||||
@ -28,10 +30,10 @@ describe('MqttProvider', () => {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(target, prop, receiver) {
|
||||
get(_target, prop, _receiver) {
|
||||
return wsClient[prop];
|
||||
},
|
||||
set(target, prop, value) {
|
||||
set(_target, prop, value) {
|
||||
wsClient[prop] = typeof value === 'function' ? jest.fn(value) : value;
|
||||
if (prop === 'onopen') {
|
||||
wsClient[prop]();
|
||||
@ -121,12 +123,24 @@ describe('MqttProvider', () => {
|
||||
</MqttProvider>
|
||||
);
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
||||
expect(screen.getByTestId('front/recordings/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}');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":true}'
|
||||
);
|
||||
expect(screen.getByTestId('front/recordings/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 { h, createContext } from 'preact';
|
||||
import useSWR, { SWRConfig } from 'swr';
|
||||
import { MqttProvider } from './mqtt';
|
||||
import produce from 'immer';
|
||||
import { useContext, useEffect, useReducer } from 'preact/hooks';
|
||||
import axios from 'axios';
|
||||
|
||||
export const FetchStatus = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
axios.defaults.baseURL = `${baseUrl}/api/`;
|
||||
|
||||
export function ApiProvider({ children }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
return (
|
||||
<Api.Provider value={{ state, dispatch }}>
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (path) => axios.get(path).then((res) => res.data),
|
||||
}}
|
||||
>
|
||||
<MqttWithConfig>{children}</MqttWithConfig>
|
||||
</Api.Provider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
function MqttWithConfig({ children }) {
|
||||
const { data, status } = useConfig();
|
||||
return status === FetchStatus.LOADED ? <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;
|
||||
const { data } = useSWR('config');
|
||||
return data ? <MqttProvider config={data}>{children}</MqttProvider> : children;
|
||||
}
|
||||
|
||||
export function useApiHost() {
|
||||
const { state } = useContext(Api);
|
||||
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);
|
||||
return baseUrl;
|
||||
}
|
||||
|
@ -64,7 +64,6 @@ export default function Button({
|
||||
color = 'blue',
|
||||
disabled = false,
|
||||
href,
|
||||
size,
|
||||
type = 'contained',
|
||||
...attrs
|
||||
}) {
|
||||
@ -81,11 +80,11 @@ export default function Button({
|
||||
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
||||
}
|
||||
|
||||
const handleMousenter = useCallback((event) => {
|
||||
const handleMousenter = useCallback(() => {
|
||||
setHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseleave = useCallback((event) => {
|
||||
const handleMouseleave = useCallback(() => {
|
||||
setHovered(false);
|
||||
}, []);
|
||||
|
||||
|
@ -7,27 +7,35 @@ export default function ButtonsTabbed({
|
||||
setHeader = null,
|
||||
headers = [''],
|
||||
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 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) => {
|
||||
return (headers.length === viewModes.length ? headers[i] : captitalize(viewModes[i]));
|
||||
}, [headers, viewModes]);
|
||||
const getHeader = useCallback(
|
||||
(i) => {
|
||||
return headers.length === viewModes.length ? headers[i] : captitalize(viewModes[i]);
|
||||
},
|
||||
[headers, viewModes]
|
||||
);
|
||||
|
||||
const handleClick = useCallback((i) => {
|
||||
setSelected(i);
|
||||
setViewMode && setViewMode(viewModes[i]);
|
||||
setHeader && setHeader(getHeader(i));
|
||||
}, [setViewMode, setHeader, setSelected, viewModes, getHeader]);
|
||||
const handleClick = useCallback(
|
||||
(i) => {
|
||||
setSelected(i);
|
||||
setViewMode && setViewMode(viewModes[i]);
|
||||
setHeader && setHeader(getHeader(i));
|
||||
},
|
||||
[setViewMode, setHeader, setSelected, viewModes, getHeader]
|
||||
);
|
||||
|
||||
setHeader && setHeader(getHeader(selected));
|
||||
return (
|
||||
<nav className="flex justify-end">
|
||||
{viewModes.map((item, i) => {
|
||||
return (
|
||||
<button onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
|
||||
<button key={i} onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
|
||||
{captitalize(item)}
|
||||
</button>
|
||||
);
|
||||
|
@ -5,7 +5,7 @@ import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
|
||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||
|
||||
const Calender = ({ onChange, calenderRef, close }) => {
|
||||
const Calendar = ({ onChange, calendarRef, close }) => {
|
||||
const keyRef = useRef([]);
|
||||
|
||||
const date = new Date();
|
||||
@ -159,8 +159,8 @@ const Calender = ({ onChange, calenderRef, close }) => {
|
||||
after,
|
||||
before:
|
||||
day.timestamp >= todayTimestamp
|
||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
? new Date(todayTimestamp).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 =
|
||||
state.monthDetails &&
|
||||
state.monthDetails.map((day, idx) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
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' : ''
|
||||
}
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
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' : ''
|
||||
}
|
||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
@ -280,7 +280,7 @@ const Calender = ({ onChange, calenderRef, close }) => {
|
||||
};
|
||||
|
||||
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="flex items-center">
|
||||
<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 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 { useResizeObserver } from '../hooks';
|
||||
|
||||
export default function CameraImage({ camera, onload, searchParams = '', stretch = false }) {
|
||||
const { data: config } = useConfig();
|
||||
const { data: config } = useSWR('config');
|
||||
const apiHost = useApiHost();
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
@ -66,7 +66,6 @@ export default function DatePicker({
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
readonly,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import { TimelineEvent } from '../Timeline/Timeline';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
|
||||
interface HistoryHeaderProps {
|
||||
event: TimelineEvent;
|
||||
|
@ -1,17 +1,29 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { useEvents } from '../../api';
|
||||
import { useSearchString } from '../../hooks/useSearchString';
|
||||
import { getNowYesterdayInLong } from '../../utils/dateUtil';
|
||||
import useSWR from 'swr';
|
||||
import axios from 'axios';
|
||||
import Timeline from '../Timeline/Timeline';
|
||||
import { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
|
||||
import { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
import type { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
import { HistoryHeader } from './HistoryHeader';
|
||||
import { HistoryVideo } from './HistoryVideo';
|
||||
|
||||
export default function HistoryViewer({ camera }) {
|
||||
const { searchString } = useSearchString(500, `camera=${camera}&after=${getNowYesterdayInLong()}`);
|
||||
const { data: events } = useEvents(searchString);
|
||||
const searchParams = {
|
||||
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 [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
|
||||
|
@ -5,7 +5,7 @@ import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
|
||||
export default function JSMpegPlayer({ camera, width, height }) {
|
||||
const playerRef = useRef();
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`;
|
||||
|
||||
useEffect(() => {
|
||||
const video = new JSMpeg.VideoElement(
|
||||
@ -16,15 +16,15 @@ export default function JSMpegPlayer({ camera, width, height }) {
|
||||
);
|
||||
|
||||
const fullscreen = () => {
|
||||
if(video.els.canvas.webkitRequestFullScreen) {
|
||||
if (video.els.canvas.webkitRequestFullScreen) {
|
||||
video.els.canvas.webkitRequestFullScreen();
|
||||
}
|
||||
else {
|
||||
video.els.canvas.mozRequestFullScreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
video.els.canvas.addEventListener('click',fullscreen)
|
||||
video.els.canvas.addEventListener('click',fullscreen);
|
||||
|
||||
return () => {
|
||||
video.destroy();
|
||||
|
@ -16,18 +16,22 @@ export default function Menu({ className, children, onDismiss, relativeTo, width
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function MenuItem({ focus, icon: Icon, label, onSelect, value }) {
|
||||
export function MenuItem({ focus, icon: Icon, label, href, onSelect, value, ...attrs }) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect && onSelect(value, label);
|
||||
}, [onSelect, value, label]);
|
||||
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
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 ${
|
||||
focus ? 'bg-gray-200 dark:bg-gray-800 dark:text-white' : ''
|
||||
}`}
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
role="option"
|
||||
{...attrs}
|
||||
>
|
||||
{Icon ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="whitespace-nowrap">{label}</div>
|
||||
</div>
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -47,7 +47,7 @@ export function Destination({ className = '', href, text, ...other }) {
|
||||
|
||||
const styleProps = {
|
||||
[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',
|
||||
};
|
||||
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
parseISO,
|
||||
startOfHour,
|
||||
differenceInMinutes,
|
||||
differenceInHours,
|
||||
differenceInHours
|
||||
} from 'date-fns';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
@ -16,7 +16,7 @@ import Menu from '../icons/Menu';
|
||||
import MenuOpen from '../icons/MenuOpen';
|
||||
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 toggle = () => setActive(!active);
|
||||
|
||||
@ -33,7 +33,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item, i) => (
|
||||
<div className="mb-2 w-full">
|
||||
<div key={i} className="mb-2 w-full">
|
||||
<div
|
||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||
@ -50,7 +50,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event) => (
|
||||
<EventCard camera={camera} event={event} delay={item.delay} />
|
||||
<EventCard key={event.id} camera={camera} event={event} delay={item.delay} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
@ -110,7 +110,7 @@ export default function RelativeModal({
|
||||
|
||||
const menu = (
|
||||
<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
|
||||
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 ${
|
||||
|
@ -4,7 +4,7 @@ import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Menu, { MenuItem } from './Menu';
|
||||
import TextField from './TextField';
|
||||
import DatePicker from './DatePicker';
|
||||
import Calender from './Calender';
|
||||
import Calendar from './Calendar';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export default function Select({
|
||||
@ -71,8 +71,8 @@ export default function Select({
|
||||
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||
|
||||
const [focused, setFocused] = useState(null);
|
||||
const [showCalender, setShowCalender] = useState(false);
|
||||
const calenderRef = useRef(null);
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const calendarRef = useRef(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@ -80,8 +80,8 @@ export default function Select({
|
||||
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||
setShowMenu(false);
|
||||
|
||||
//show calender date range picker
|
||||
if (value === 'custom_range') return setShowCalender(true);
|
||||
//show calendar date range picker
|
||||
if (value === 'custom_range') return setShowCalendar(true);
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange, options, propSelected, setSelected]
|
||||
@ -110,7 +110,7 @@ export default function Select({
|
||||
setSelected(focused);
|
||||
if (options[focused].value === 'custom_range') {
|
||||
setShowMenu(false);
|
||||
return setShowCalender(true);
|
||||
return setShowCalendar(true);
|
||||
}
|
||||
|
||||
onChange && onChange(options[focused].value);
|
||||
@ -184,8 +184,8 @@ export default function Select({
|
||||
|
||||
useEffect(() => {
|
||||
const addBackDrop = (e) => {
|
||||
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
|
||||
setShowCalender(false);
|
||||
if (showCalendar && !findDOMNodes(calendarRef.current).contains(e.target)) {
|
||||
setShowCalendar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', addBackDrop);
|
||||
@ -193,7 +193,7 @@ export default function Select({
|
||||
return function cleanup() {
|
||||
window.removeEventListener('click', addBackDrop);
|
||||
};
|
||||
}, [showCalender]);
|
||||
}, [showCalendar]);
|
||||
|
||||
switch (type) {
|
||||
case 'datepicker':
|
||||
@ -208,9 +208,9 @@ export default function Select({
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={datePickerValue}
|
||||
/>
|
||||
{showCalender && (
|
||||
{showCalendar && (
|
||||
<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>
|
||||
)}
|
||||
{showMenu ? (
|
||||
@ -223,7 +223,7 @@ export default function Select({
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
// case 'dropdown':
|
||||
// case 'dropdown':
|
||||
default:
|
||||
return (
|
||||
<Fragment>
|
||||
|
@ -4,14 +4,11 @@ import { useCallback, useState } from 'preact/hooks';
|
||||
export default function Switch({ checked, id, onChange, label, labelPosition = 'before' }) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
if (onChange) {
|
||||
onChange(id, !checked);
|
||||
}
|
||||
},
|
||||
[id, onChange, checked]
|
||||
);
|
||||
const handleChange = useCallback(() => {
|
||||
if (onChange) {
|
||||
onChange(id, !checked);
|
||||
}
|
||||
}, [id, onChange, checked]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onChange && setFocused(true);
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils';
|
||||
import { ScrollPermission } from './ScrollPermission';
|
||||
import type { ScrollPermission } from './ScrollPermission';
|
||||
import { TimelineBlocks } from './TimelineBlocks';
|
||||
import { TimelineChangeEvent } from './TimelineChangeEvent';
|
||||
import type { TimelineChangeEvent } from './TimelineChangeEvent';
|
||||
import { DisabledControls, TimelineControls } from './TimelineControls';
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil';
|
||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlockViewProps {
|
||||
block: TimelineEventBlock;
|
||||
|
@ -3,7 +3,7 @@ import { useMemo } from 'preact/hooks';
|
||||
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
|
||||
import { convertRemToPixels } from '../../utils/windowUtils';
|
||||
import { TimelineBlockView } from './TimelineBlockView';
|
||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlocksProps {
|
||||
timeline: TimelineEventBlock[];
|
||||
@ -36,7 +36,7 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
||||
...block,
|
||||
yOffset: block.yOffset + timelineBlockOffset,
|
||||
};
|
||||
return <TimelineBlockView block={updatedBlock} onClick={onClickHandler} />;
|
||||
return <TimelineBlockView key={block.id} block={updatedBlock} onClick={onClickHandler} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent: TimelineEvent;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
|
@ -6,14 +6,14 @@ describe('Tooltip', () => {
|
||||
test('renders in a relative position', async () => {
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 40, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
@ -34,14 +34,14 @@ describe('Tooltip', () => {
|
||||
window.innerWidth = 1024;
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 1000,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
@ -61,14 +61,14 @@ describe('Tooltip', () => {
|
||||
test('if too far left, renders to the right', async () => {
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
@ -89,14 +89,14 @@ describe('Tooltip', () => {
|
||||
window.scrollY = 90;
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
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 { memo } from 'preact/compat';
|
||||
|
||||
export function Clip({ className = '' }) {
|
||||
export function Clip({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,22 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Delete({ className = '' }) {
|
||||
export function Delete({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M6 21h12V7H6v14zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
|
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 { memo } from 'preact/compat';
|
||||
|
||||
export function Play({ className = '' }) {
|
||||
export function Play() {
|
||||
return (
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Previous({ className = '' }) {
|
||||
export function Previous() {
|
||||
return (
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20,5V19L13,12M6,5V19H4V5M13,5V19L6,12" />
|
||||
|
@ -1,12 +1,22 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function Snapshot({ className = '' }) {
|
||||
export function Snapshot({ className = 'h-6 w-6', stroke = 'currentColor', onClick = () => {} }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<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" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,22 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function StarRecording({ className = '' }) {
|
||||
export function StarRecording({ className = 'h-6 w-6', stroke = 'currentColor', fill = 'none', onClick = () => {} }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||
<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" />
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
|
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;
|
||||
}
|
||||
|
||||
/*
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scroll {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -10,18 +10,19 @@ import Switch from '../components/Switch';
|
||||
import ButtonsTabbed from '../components/ButtonsTabbed';
|
||||
import { usePersistence } from '../context';
|
||||
import { useCallback, useMemo, useState } from 'preact/hooks';
|
||||
import { useApiHost, useConfig } from '../api';
|
||||
import { useApiHost } from '../api';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const emptyObject = Object.freeze({});
|
||||
|
||||
export default function Camera({ camera }) {
|
||||
const { data: config } = useConfig();
|
||||
const { data: config } = useSWR('config');
|
||||
const apiHost = useApiHost();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('live');
|
||||
|
||||
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 handleSetOption = useCallback(
|
||||
|
@ -5,10 +5,11 @@ import Heading from '../components/Heading.jsx';
|
||||
import Switch from '../components/Switch.jsx';
|
||||
import { useResizeObserver } from '../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 }) {
|
||||
const { data: config } = useConfig();
|
||||
export default function CameraMasks({ camera }) {
|
||||
const { data: config } = useSWR('config');
|
||||
const apiHost = useApiHost();
|
||||
const imageRef = useRef(null);
|
||||
const [snap, setSnap] = useState(true);
|
||||
@ -20,10 +21,7 @@ export default function CameraMasks({ camera, url }) {
|
||||
zones,
|
||||
} = cameraConfig;
|
||||
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
} = cameraConfig.detect;
|
||||
const { width, height } = cameraConfig.detect;
|
||||
|
||||
const [{ width: scaledWidth }] = useResizeObserver(imageRef);
|
||||
const imageScale = scaledWidth / width;
|
||||
@ -100,7 +98,7 @@ export default function CameraMasks({ camera, url }) {
|
||||
const handleCopyMotionMasks = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` motion:
|
||||
mask:
|
||||
${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
|
||||
${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
|
||||
}, [motionMaskPoints]);
|
||||
|
||||
// Zone methods
|
||||
@ -273,16 +271,16 @@ ${Object.keys(objectMaskPoints)
|
||||
);
|
||||
}
|
||||
|
||||
function maskYamlKeyPrefix(points) {
|
||||
function maskYamlKeyPrefix() {
|
||||
return ' - ';
|
||||
}
|
||||
|
||||
function zoneYamlKeyPrefix(points, key) {
|
||||
function zoneYamlKeyPrefix(_points, key) {
|
||||
return ` ${key}:
|
||||
coordinates: `;
|
||||
}
|
||||
|
||||
function objectYamlKeyPrefix(points, key, subkey) {
|
||||
function objectYamlKeyPrefix() {
|
||||
return ' - ';
|
||||
}
|
||||
|
||||
@ -364,6 +362,7 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
|
||||
? null
|
||||
: scaledPoints.map(([x, y], i) => (
|
||||
<PolyPoint
|
||||
key={i}
|
||||
boundingRef={boundingRef}
|
||||
index={i}
|
||||
onMove={handleMovePoint}
|
||||
@ -466,6 +465,7 @@ function MaskValues({
|
||||
) : null}
|
||||
{points[mainkey].map((item, subkey) => (
|
||||
<Item
|
||||
key={subkey}
|
||||
mainkey={mainkey}
|
||||
subkey={subkey}
|
||||
editing={editing}
|
||||
@ -481,6 +481,7 @@ function MaskValues({
|
||||
}
|
||||
return (
|
||||
<Item
|
||||
key={mainkey}
|
||||
mainkey={mainkey}
|
||||
editing={editing}
|
||||
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 (
|
||||
<span
|
||||
data-key={mainkey}
|
||||
|
@ -2,14 +2,14 @@ import { h, Fragment } from 'preact';
|
||||
import JSMpegPlayer from '../components/JSMpegPlayer';
|
||||
import Heading from '../components/Heading';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useConfig } from '../api';
|
||||
import useSWR from 'swr';
|
||||
import { Tabs, TextTab } from '../components/Tabs';
|
||||
import { LiveChip } from '../components/LiveChip';
|
||||
import { DebugCamera } from '../components/DebugCamera';
|
||||
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
|
||||
|
||||
export default function Camera({ camera }) {
|
||||
const { data: config } = useConfig();
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
const [playerType, setPlayerType] = useState('live');
|
||||
|
||||
|
@ -6,30 +6,33 @@ import ClipIcon from '../icons/Clip';
|
||||
import MotionIcon from '../icons/Motion';
|
||||
import SnapshotIcon from '../icons/Snapshot';
|
||||
import { useDetectState, useRecordingsState, useSnapshotsState } from '../api/mqtt';
|
||||
import { useConfig, FetchStatus } from '../api';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function Cameras() {
|
||||
const { data: config, status } = useConfig();
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
return status !== FetchStatus.LOADED ? (
|
||||
return !config ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
|
||||
{Object.entries(config.cameras).map(([camera, conf]) => (
|
||||
<Camera name={camera} conf={conf} />
|
||||
<Camera key={camera} name={camera} conf={conf} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Camera({ name, conf }) {
|
||||
function Camera({ name }) {
|
||||
const { payload: detectValue, send: sendDetect } = useDetectState(name);
|
||||
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
|
||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||
const href = `/cameras/${name}`;
|
||||
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]);
|
||||
const icons = useMemo(
|
||||
() => [
|
||||
|
@ -4,21 +4,21 @@ import Button from '../components/Button';
|
||||
import Heading from '../components/Heading';
|
||||
import Link from '../components/Link';
|
||||
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 { useCallback } from 'preact/hooks';
|
||||
|
||||
const emptyObject = Object.freeze({});
|
||||
|
||||
export default function Debug() {
|
||||
const { data: config } = useConfig();
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
const {
|
||||
value: { payload: 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 detectorDataKeys = Object.keys(detectors ? detectors[detectorNames[0]] : emptyObject);
|
||||
@ -50,13 +50,13 @@ export default function Debug() {
|
||||
<Tr>
|
||||
<Th>detector</Th>
|
||||
{detectorDataKeys.map((name) => (
|
||||
<Th>{name.replace('_', ' ')}</Th>
|
||||
<Th key={name}>{name.replace('_', ' ')}</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{detectorNames.map((detector, i) => (
|
||||
<Tr index={i}>
|
||||
<Tr key={i} index={i}>
|
||||
<Td>{detector}</Td>
|
||||
{detectorDataKeys.map((name) => (
|
||||
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
|
||||
@ -73,13 +73,13 @@ export default function Debug() {
|
||||
<Tr>
|
||||
<Th>camera</Th>
|
||||
{cameraDataKeys.map((name) => (
|
||||
<Th>{name.replace('_', ' ')}</Th>
|
||||
<Th key={name}>{name.replace('_', ' ')}</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{cameraNames.map((camera, i) => (
|
||||
<Tr index={i}>
|
||||
<Tr key={i} index={i}>
|
||||
<Td>
|
||||
<Link href={`/cameras/${camera}`}>{camera}</Link>
|
||||
</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 RecordingPlaylist from '../components/RecordingPlaylist';
|
||||
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 }) {
|
||||
const apiHost = useApiHost();
|
||||
const { data, status } = useRecording(camera);
|
||||
const { data } = useSWR(`${camera}/recordings`);
|
||||
|
||||
if (status !== FetchStatus.LOADED) {
|
||||
if (!data) {
|
||||
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');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getCamera(url, cb, props) {
|
||||
export async function getCamera(_url, _cb, _props) {
|
||||
const module = await import('./Camera.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getCameraV2(url, cb, props) {
|
||||
export async function getCameraV2(_url, _cb, _props) {
|
||||
const module = await import('./Camera_V2.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getEvent(url, cb, props) {
|
||||
const module = await import('./Event.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getBirdseye(url, cb, props) {
|
||||
export async function getBirdseye(_url, _cb, _props) {
|
||||
const module = await import('./Birdseye.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getEvents(url, cb, props) {
|
||||
const module = await import('./Events');
|
||||
export async function getEvents(_url, _cb, _props) {
|
||||
const module = await import('./Events.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getRecording(url, cb, props) {
|
||||
export async function getRecording(_url, _cb, _props) {
|
||||
const module = await import('./Recording.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getDebug(url, cb, props) {
|
||||
export async function getDebug(_url, _cb, _props) {
|
||||
const module = await import('./Debug.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getStyleGuide(url, cb, props) {
|
||||
export async function getStyleGuide(_url, _cb, _props) {
|
||||
const module = await import('./StyleGuide.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
||||
import { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
|
||||
import type { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
||||
import type { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
|
||||
import { epochToLong, longToDate } from '../dateUtil';
|
||||
|
||||
export const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
|
||||
@ -51,15 +51,15 @@ export const getTimelineEventBlocksFromTimelineEvents = (events: TimelineEvent[]
|
||||
return rows;
|
||||
})
|
||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
||||
}
|
||||
};
|
||||
|
||||
export const findLargestYOffsetInBlocks = (blocks: TimelineEventBlock[]): number => {
|
||||
return blocks.reduce((largestYOffset, current) => {
|
||||
if (current.yOffset > largestYOffset) {
|
||||
return current.yOffset
|
||||
return current.yOffset;
|
||||
}
|
||||
return largestYOffset;
|
||||
}, 0)
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset: number): number => {
|
||||
@ -68,6 +68,6 @@ export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset:
|
||||
const startTimeEpoch = firstBlock.startTime.getTime();
|
||||
const endTimeEpoch = Date.now();
|
||||
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 twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||
}
|
||||
};
|
||||
|
||||
const getNowYesterday = (): Date => {
|
||||
return getDateTimeYesterday(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
export const getNowYesterdayInLong = (): number => {
|
||||
return dateToLong(getNowYesterday());
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
||||
import type { TimelineEvent } from '../../components/Timeline/TimelineEvent';
|
||||
|
||||
export const getColorFromTimelineEvent = (event: TimelineEvent) => {
|
||||
const { label } = event;
|
||||
|
@ -1,3 +1,3 @@
|
||||
export const convertRemToPixels = (rem: number): number => {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
||||
};
|
||||
|
@ -1,5 +1,9 @@
|
||||
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',
|
||||
theme: {
|
||||
extend: {
|
||||
@ -20,8 +24,7 @@ module.exports = {
|
||||
none: '',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
};
|
||||
|
@ -1,15 +1,28 @@
|
||||
{
|
||||
"include": ["./src/**/*.tsx", "./src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES2019",
|
||||
"jsx": "react",
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"lib": [
|
||||
"ES2019"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.tsx",
|
||||
"./src/**/*.ts"
|
||||
]
|
||||
}
|
||||
"baseUrl": "./",
|
||||
/* paths - import rewriting/resolving */
|
||||
"paths": {
|
||||
// If you configured any Snowpack aliases, add them here.
|
||||
// Add this line to get types for streaming imports (packageOptions.source="remote"):
|
||||
// "*": [".snowpack/types/*"]
|
||||
// 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