Adjust for incoming changes

This commit is contained in:
Nick Mowen
2022-03-11 07:10:39 -07:00
149 changed files with 9856 additions and 19606 deletions
-1
View File
@@ -1 +0,0 @@
node_modules
+2 -2
View File
@@ -1,2 +1,2 @@
build/*
node_modules/*
dist/*
node_modules/*
+29
View File
@@ -0,0 +1,29 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["eslint:recommended", "preact", "prettier"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"comma-dangle": ["error", { "objects": "always-multiline", "arrays": "always-multiline" }],
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-console": "error"
},
"overrides": [
{
"files": ["**/*.{ts,tsx}"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
}
]
}
-157
View File
@@ -1,157 +0,0 @@
module.exports = {
parser: '@babel/eslint-parser',
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
experimentalObjectRestSpread: 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'],
},
},
},
overrides: [
{
files: ['*.{ts,tsx}'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['plugin:@typescript-eslint/recommended'],
settings: {
'import/resolver': {
node: {
extensions: ['.ts', '.tsx'],
},
},
},
},
],
};
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+4
View File
@@ -0,0 +1,4 @@
{
"printWidth": 120,
"singleQuote": true
}
-3
View File
@@ -1,3 +0,0 @@
# Frigate Web UI
For installation and contributing instructions, please follow the [Contributing Docs](https://blakeblackshear.github.io/frigate/contributing).
+73
View File
@@ -0,0 +1,73 @@
import { rest } from 'msw';
import { API_HOST } from '../src/env';
export const handlers = [
rest.get(`${API_HOST}/api/config`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
mqtt: {
stats_interval: 60,
},
service: {
version: '0.8.3',
},
cameras: {
front: {
name: 'front',
objects: { track: ['taco', 'cat', 'dog'] },
record: { enabled: true },
detect: { width: 1280, height: 720 },
snapshots: {},
live: { height: 720 },
},
side: {
name: 'side',
objects: { track: ['taco', 'cat', 'dog'] },
record: { enabled: false },
detect: { width: 1280, height: 720 },
snapshots: {},
live: { height: 720 },
},
},
})
);
}),
rest.get(`${API_HOST}/api/stats`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
detection_fps: 0.0,
detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
side: {
camera_fps: 6.9,
capture_pid: 71,
detection_fps: 0.0,
pid: 60,
process_fps: 0.0,
skipped_fps: 0.0,
},
service: { uptime: 34812, version: '0.8.1-d376f6b' },
})
);
}),
rest.get(`${API_HOST}/api/events`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json(
new Array(12).fill(null).map((v, i) => ({
end_time: 1613257337 + i,
has_clip: true,
has_snapshot: true,
id: i,
label: 'person',
start_time: 1613257326 + i,
top_score: Math.random(),
zones: ['front_patio'],
thumbnail: '/9j/4aa...',
}))
)
);
}),
];
+6
View File
@@ -0,0 +1,6 @@
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// This configures a request mocking server with the given request handlers.
export const server = setupServer(...handlers);
+13 -2
View File
@@ -1,5 +1,6 @@
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom/extend-expect';
import { server } from './server.js';
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -13,6 +14,16 @@ Object.defineProperty(window, 'matchMedia', {
}),
});
window.fetch = () => Promise.resolve();
jest.mock('../src/env');
// Establish API mocking before all tests.
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => {
server.resetHandlers();
});
// Clean up after the tests are finished.
afterAll(() => server.close());
+24
View File
@@ -0,0 +1,24 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { ApiProvider } from '../src/api';
const Wrapper = ({ children }) => {
return (
<ApiProvider
options={{
dedupingInterval: 0,
provider: () => new Map(),
}}
>
{children}
</ApiProvider>
);
};
const customRender = (ui, options) => render(ui, { wrapper: Wrapper, ...options });
// re-export everything
export * from '@testing-library/preact';
// override render method
export { customRender as render };

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 558 B

After

Width:  |  Height:  |  Size: 558 B

Before

Width:  |  Height:  |  Size: 800 B

After

Width:  |  Height:  |  Size: 800 B

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 534 B

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+9 -9
View File
@@ -1,25 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<meta charset="UTF-8" />
<link rel="icon" href="/images/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frigate</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#3b82f7" />
<link rel="mask-icon" href="/images/safari-pinned-tab.svg" color="#3b82f7" />
<meta name="msapplication-TileColor" content="#3b82f7" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#111827" media="(prefers-color-scheme: dark)" />
</head>
<body>
<div id="root" class="z-0"></div>
<div id="app" class="z-0"></div>
<div id="dialogs" class="z-0"></div>
<div id="menus" class="z-0"></div>
<div id="tooltips" class="z-0"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/dist/index.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+195 -9
View File
@@ -1,12 +1,198 @@
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
moduleFileExtensions: ['js', 'jsx'],
name: 'react-component-benchmark',
resetMocks: true,
roots: ['<rootDir>'],
setupFilesAfterEnv: ['<rootDir>/config/setupTests.js'],
testEnvironment: 'jsdom',
timers: 'fake',
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls, instances and results before every test
// clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js'
}
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js',
'^testing-library$': '<rootDir>/config/testing-library',
'^react$': 'preact/compat',
'^react-dom$': 'preact/compat',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
resetMocks: true,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
roots: ['<rootDir>'],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
setupFilesAfterEnv: ['<rootDir>/config/setupTests.js'],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: 'fake',
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};
-8
View File
@@ -1,8 +0,0 @@
{
"compilerOptions": {
"target": "ES2019",
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
}
}
+8288 -15407
View File
File diff suppressed because it is too large Load Diff
+35 -39
View File
@@ -1,54 +1,50 @@
{
"name": "frigate",
"private": true,
"version": "0.0.0",
"scripts": {
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
"start:custom": "snowpack dev",
"prebuild": "rimraf build",
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
"lint": "npm run lint:cmd -- --fix",
"lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
"dev": "vite",
"lint": "eslint ./ --ext .jsx,.js,.tsx,.ts",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "jest"
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^5.0.1",
"date-fns": "^2.21.3",
"idb-keyval": "^5.0.2",
"immer": "^9.0.6",
"preact": "^10.5.9",
"axios": "^0.26.0",
"date-fns": "^2.28.0",
"idb-keyval": "^6.1.0",
"immer": "^9.0.12",
"preact": "^10.6.6",
"preact-async-route": "^2.2.1",
"preact-router": "^3.2.1",
"video.js": "^7.15.4",
"videojs-playlist": "^4.3.1",
"videojs-seek-buttons": "^2.0.1"
"preact-router": "^4.0.1",
"swr": "^1.2.2",
"video.js": "^7.17.0",
"videojs-playlist": "^5.0.0",
"videojs-seek-buttons": "^2.2.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.12.13",
"@babel/plugin-transform-react-jsx": "^7.12.13",
"@babel/preset-env": "^7.12.13",
"@babel/preset-env": "^7.16.11",
"@babel/preset-typescript": "^7.16.7",
"@prefresh/snowpack": "^3.0.1",
"@snowpack/plugin-postcss": "^1.1.0",
"@testing-library/jest-dom": "^5.11.9",
"@preact/preset-vite": "^2.1.5",
"@tailwindcss/forms": "^0.5.0",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/preact": "^2.0.1",
"@testing-library/user-event": "^12.7.1",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"autoprefixer": "^10.2.1",
"cross-env": "^7.0.3",
"eslint": "^7.19.0",
"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",
"jest": "^26.6.3",
"postcss": "^8.2.10",
"postcss-cli": "^8.3.1",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"snowpack": "^3.0.11",
"snowpack-plugin-hash": "^0.14.2",
"tailwindcss": "^2.0.2"
"@testing-library/preact-hooks": "^1.1.0",
"@testing-library/user-event": "^13.5.0",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^26.1.1",
"jest": "^27.5.1",
"msw": "^0.38.2",
"postcss": "^8.4.7",
"prettier": "^2.5.1",
"tailwindcss": "^3.0.23",
"typescript": "^4.5.4",
"vite": "^2.8.0"
}
}
+5 -2
View File
@@ -1,3 +1,6 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')],
};
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
printWidth: 120,
singleQuote: true,
jsxSingleQuote: true,
useTabs: false,
};
-19
View File
@@ -1,19 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/images/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
-19
View File
@@ -1,19 +0,0 @@
module.exports = {
mount: {
public: { url: '/', static: true },
src: { url: '/dist' },
},
plugins: ['@snowpack/plugin-postcss', '@prefresh/snowpack', 'snowpack-plugin-hash'],
routes: [{ match: 'all', src: '(?!.*(.svg|.gif|.json|.jpg|.jpeg|.png|.js)).*', dest: '/index.html' }],
optimize: {
bundle: false,
minify: true,
treeshake: true,
},
packageOptions: {
sourcemap: false,
},
buildOptions: {
sourcemap: false,
},
};
+4 -3
View File
@@ -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>
+1 -1
View File
@@ -19,7 +19,7 @@ export default function AppBar() {
const { send: sendRestart } = useRestart();
const handleSelectDarkMode = useCallback(
(value, label) => {
(value) => {
setDarkMode(value);
setShowMoreMenu(false);
},
+14 -7
View File
@@ -3,14 +3,15 @@ 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 { useMemo } from 'preact/hooks';
import useSWR from 'swr';
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
export default function Sidebar() {
const { data: config } = useConfig();
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
const { birdseye } = config;
const { data: config } = useSWR('config');
if (!config) {
return null;
}
const { cameras, birdseye } = config;
return (
<NavigationDrawer header={<Header />}>
@@ -20,7 +21,10 @@ export default function Sidebar() {
matches ? (
<Fragment>
<Separator />
{cameras.filter(([cam, conf]) => conf.gui.show).sort(([aCam, aConf], [bCam, bConf]) => aConf.gui.order === bConf.gui.order ? 0 : (aConf.gui.order > bConf.gui.order ? 1 : -1)).map(([camera]) => (
{Object.entries(cameras)
.filter(([cam, conf]) => conf.gui.show)
.sort(([aCam, aConf], [bCam, bConf]) => aConf.gui.order === bConf.gui.order ? 0 : (aConf.gui.order > bConf.gui.order ? 1 : -1))
.map(([camera]) => (
<Destination key={camera} href={`/cameras/${camera}`} text={camera} />
))}
<Separator />
@@ -33,7 +37,10 @@ export default function Sidebar() {
matches ? (
<Fragment>
<Separator />
{cameras.filter(([cam, conf]) => conf.gui.show).sort(([aCam, aConf], [bCam, bConf]) => aConf.gui.order === bConf.gui.order ? 0 : (aConf.gui.order > bConf.gui.order ? 1 : -1)).map(([camera, conf]) => {
{Object.entries(cameras)
.filter(([cam, conf]) => conf.gui.show)
.sort(([aCam, aConf], [bCam, bConf]) => aConf.gui.order === bConf.gui.order ? 0 : (aConf.gui.order > bConf.gui.order ? 1 : -1))
.map(([camera, conf]) => {
if (conf.record.enabled) {
return (
<Destination
+1 -9
View File
@@ -1,25 +1,17 @@
import { h } from 'preact';
import * as Api from '../api';
import * as IDB from 'idb-keyval';
import * as PreactRouter from 'preact-router';
import App from '../App';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('App', () => {
let mockUseConfig;
beforeEach(() => {
jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined));
jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true));
mockUseConfig = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: { cameras: { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } } } },
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(PreactRouter, 'Router').mockImplementation(() => <div data-testid="router" />);
});
test('shows a loading indicator while loading', async () => {
mockUseConfig.mockReturnValue({ status: 'loading' });
render(<App />);
await screen.findByTestId('app');
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
+1 -1
View File
@@ -1,7 +1,7 @@
import { h } from 'preact';
import * as Context from '../context';
import AppBar from '../AppBar';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('AppBar', () => {
beforeEach(() => {
+3 -26
View File
@@ -1,40 +1,17 @@
import { h } from 'preact';
import * as Api from '../api';
import * as Context from '../context';
import Sidebar from '../Sidebar';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Sidebar', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: {
cameras: {
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true }, gui: { order: 0, show: true } },
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false }, gui: { order: 0, show: true } },
},
},
}));
jest.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer: () => {} }));
});
test('does not render cameras by default', async () => {
render(<Sidebar />);
const { findByText } = render(<Sidebar />);
await findByText('Cameras');
expect(screen.queryByRole('link', { name: 'front' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
});
test('render cameras if in camera route', async () => {
window.history.replaceState({}, 'Cameras', '/cameras/front');
render(<Sidebar />);
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).toBeInTheDocument();
});
test('render cameras if in record route', async () => {
window.history.replaceState({}, 'Front Recordings', '/recording/front');
render(<Sidebar />);
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
});
});
+2 -100
View File
@@ -1,7 +1,7 @@
import { h } from 'preact';
import * as Mqtt from '../mqtt';
import { ApiProvider, useFetch, useApiHost } from '..';
import { render, screen } from '@testing-library/preact';
import { ApiProvider, useApiHost } from '..';
import { render, screen } from 'testing-library';
describe('useApiHost', () => {
beforeEach(() => {
@@ -21,101 +21,3 @@ describe('useApiHost', () => {
expect(screen.queryByText('http://base-url.local:5000')).toBeInTheDocument();
});
});
function Test() {
const { data, status } = useFetch('/api/tacos');
return (
<div>
<span>{data ? data.returnData : ''}</span>
<span>{status}</span>
</div>
);
}
describe('useFetch', () => {
let fetchSpy;
beforeEach(() => {
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => {
if (url.endsWith('/api/config')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
}, 1);
});
});
});
test('loads data', async () => {
render(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
expect(screen.queryByText('yep')).not.toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('loaded');
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
expect(screen.queryByText('loaded')).toBeInTheDocument();
expect(screen.queryByText('yep')).toBeInTheDocument();
});
test('sets error if response is not okay', async () => {
jest.spyOn(window, 'fetch').mockImplementation((url) => {
if (url.includes('/config')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: false });
}, 1);
});
});
render(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('error');
});
test('does not re-fetch if the query has already been made', async () => {
const { rerender } = render(
<ApiProvider>
<Test key={0} />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
expect(screen.queryByText('yep')).not.toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('loaded');
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
rerender(
<ApiProvider>
<Test key={1} />
</ApiProvider>
);
expect(screen.queryByText('loaded')).toBeInTheDocument();
expect(screen.queryByText('yep')).toBeInTheDocument();
jest.runAllTimers();
// once for /api/config, once for /api/tacos
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
+24 -10
View File
@@ -1,14 +1,16 @@
import { h } from 'preact';
import { Mqtt, MqttProvider, useMqtt } from '../mqtt';
import { useCallback, useContext } from 'preact/hooks';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
function Test() {
const { state } = useContext(Mqtt);
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}'
);
});
});
+15 -152
View File
@@ -1,166 +1,29 @@
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',
};
axios.defaults.baseURL = `${baseUrl}/api/`;
const initialState = Object.freeze({
host: baseUrl,
queries: {},
});
const Api = createContext(initialState);
function reducer(state, { type, payload }) {
switch (type) {
case 'REQUEST': {
const { url, fetchId } = payload;
const data = state.queries[url]?.data || null;
return produce(state, (draftState) => {
draftState.queries[url] = { status: FetchStatus.LOADING, data, fetchId };
});
}
case 'RESPONSE': {
const { url, ok, data, fetchId } = payload;
return produce(state, (draftState) => {
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data, fetchId };
});
}
case 'DELETE': {
const { eventId } = payload;
return produce(state, (draftState) => {
Object.keys(draftState.queries).map((url) => {
draftState.queries[url].deletedId = eventId;
});
});
}
default:
return state;
}
}
export function ApiProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
export function ApiProvider({ children, options }) {
return (
<Api.Provider value={{ state, dispatch }}>
<SWRConfig
value={{
fetcher: (path) => axios.get(path).then((res) => res.data),
...options,
}}
>
<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;
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { h } from 'preact';
import { h, JSX } from 'preact';
interface BubbleButtonProps {
variant?: 'primary' | 'secondary';
children?: preact.JSX.Element;
children?: JSX.Element;
disabled?: boolean;
className?: string;
onClick?: () => void;
+2 -3
View File
@@ -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);
}, []);
+19 -11
View File
@@ -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, dateRange }) => {
const keyRef = useRef([]);
const date = new Date();
@@ -36,7 +36,7 @@ const Calender = ({ onChange, calenderRef, close }) => {
year,
month,
selectedDay: null,
timeRange: { before: null, after: null },
timeRange: dateRange,
monthDetails: null,
});
@@ -98,7 +98,11 @@ const Calender = ({ onChange, calenderRef, close }) => {
);
useEffect(() => {
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
setState((prev) => ({
...prev,
selectedDay: todayTimestamp,
monthDetails: getMonthDetails(year, month),
}));
}, [year, month, getMonthDetails]);
useEffect(() => {
@@ -150,7 +154,10 @@ const Calender = ({ onChange, calenderRef, close }) => {
// user has selected a date < after, reset values
if (after === null || day.timestamp < after) {
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
timeRange = {
before: new Date(day.timestamp).setHours(24, 0, 0, 0),
after: day.timestamp,
};
}
// user has selected a date > after
@@ -280,7 +287,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">
@@ -314,7 +321,7 @@ const Calender = ({ onChange, calenderRef, close }) => {
<ArrowRight className="h-2/6" />
</div>
</div>
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
<div className="w-1/6 relative flex justify-around" tabIndex={104} onClick={() => setYear(1)}>
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
<ArrowRightDouble className="h-2/6" />
</div>
@@ -326,4 +333,4 @@ const Calender = ({ onChange, calenderRef, close }) => {
);
};
export default Calender;
export default Calendar;
+7 -6
View File
@@ -1,19 +1,20 @@
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);
const canvasRef = useRef(null);
const [{ width: availableWidth }] = useResizeObserver(containerRef);
const { name } = config.cameras[camera];
const { width, height } = config.cameras[camera].detect;
const { name } = config ? config.cameras[camera] : '';
const { width, height } = config ? config.cameras[camera].detect : { width: 1, height: 1 };
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
@@ -36,11 +37,11 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
);
useEffect(() => {
if (scaledHeight === 0 || !canvasRef.current) {
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight]);
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (
<div className="relative w-full" ref={containerRef}>
-1
View File
@@ -66,7 +66,6 @@ export default function DatePicker({
onBlur,
onChangeText,
onFocus,
readonly,
trailingIcon: TrailingIcon,
value: propValue = '',
...props
@@ -1,9 +1,9 @@
import { h } from 'preact';
import Heading from '../Heading';
import { TimelineEvent } from '../Timeline/Timeline';
import type { TimelineEvent } from '../Timeline/TimelineEvent';
interface HistoryHeaderProps {
event: TimelineEvent;
event?: TimelineEvent;
className?: string;
}
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {
@@ -15,7 +15,7 @@ interface VideoProperties {
}
interface HistoryVideoProps {
id: string;
id?: string;
isPlaying: boolean;
currentTime: number;
onTimeUpdate?: (event: OnTimeUpdateEvent) => void;
@@ -32,9 +32,13 @@ export const HistoryVideo = ({
onPlay,
}: HistoryVideoProps) => {
const apiHost = useApiHost();
const videoRef = useRef<HTMLVideoElement>();
const [videoHeight, setVideoHeight] = useState<number>(undefined);
const [videoProperties, setVideoProperties] = useState<VideoProperties>(undefined);
const videoRef = useRef<HTMLVideoElement|null>(null);
const [videoHeight, setVideoHeight] = useState<number>(0);
const [videoProperties, setVideoProperties] = useState<VideoProperties>({
posterUrl: '',
videoUrl: '',
height: 0,
});
const currentVideo = videoRef.current;
if (currentVideo && !videoHeight) {
@@ -48,7 +52,7 @@ export const HistoryVideo = ({
const idExists = !isNullOrUndefined(id);
if (idExists) {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current = undefined;
videoRef.current = null;
}
setVideoProperties({
@@ -57,7 +61,11 @@ export const HistoryVideo = ({
height: videoHeight,
});
} else {
setVideoProperties(undefined);
setVideoProperties({
posterUrl: '',
videoUrl: '',
height: 0,
});
}
}, [id, videoHeight, videoRef, apiHost]);
@@ -78,7 +86,7 @@ export const HistoryVideo = ({
const video = videoRef.current;
const videoExists = !isNullOrUndefined(video);
if (videoExists) {
if (video && videoExists) {
if (videoIsPlaying) {
attemptPlayVideo(video);
} else {
@@ -91,7 +99,7 @@ export const HistoryVideo = ({
const video = videoRef.current;
const videoExists = !isNullOrUndefined(video);
const hasSeeked = currentTime >= 0;
if (videoExists && hasSeeked) {
if (video && videoExists && hasSeeked) {
video.currentTime = currentTime;
}
}, [currentTime, videoRef]);
@@ -1,22 +1,34 @@
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);
export default function HistoryViewer({ camera }: {camera: string}) {
const searchParams = {
before: null,
after: null,
camera,
label: 'all',
zone: 'all',
};
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
const [isPlaying, setIsPlaying] = useState(undefined);
const [currentTime, setCurrentTime] = useState<number>(undefined);
// TODO: refactor
const eventsFetcher = (path: string, params: {[name:string]: string|number}) => {
params = { ...params, include_thumbnails: 0, limit: 500 };
return axios.get<TimelineEvent[]>(path, { params }).then((res) => res.data);
};
const { data: events } = useSWR(['events', searchParams], eventsFetcher);
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(new Date().getTime());
useEffect(() => {
if (events) {
+4 -4
View File
@@ -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();
+7 -3
View File
@@ -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>
);
}
+1 -1
View File
@@ -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',
};
+4 -4
View File
@@ -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>
))}
+1 -1
View File
@@ -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 ${
+12 -12
View File
@@ -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>
+5 -8
View File
@@ -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);
+15 -12
View File
@@ -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[];
@@ -16,7 +16,7 @@ interface TimelineProps {
}
export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) {
const timelineContainerRef = useRef<HTMLDivElement>(undefined);
const timelineContainerRef = useRef<HTMLDivElement>(null);
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
@@ -24,10 +24,10 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
next: true,
previous: false,
});
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
const [timelineOffset, setTimelineOffset] = useState<number>(0);
const [markerTime, setMarkerTime] = useState<Date>(new Date());
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout>();
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
allowed: true,
resetAfterSeeked: false,
@@ -51,7 +51,7 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
);
const scrollToEvent = useCallback(
(event, offset = 0) => {
(event: TimelineEventBlock, offset = 0) => {
scrollToPosition(event.positionX + offset - timelineOffset);
},
[timelineOffset, scrollToPosition]
@@ -137,7 +137,9 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
};
const waitForSeekComplete = (markerTime: Date) => {
clearTimeout(scrollTimeout);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150));
};
@@ -161,11 +163,12 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
}
return new Date();
}, [timeline, timelineContainerRef]);
useEffect(() => {
if (timelineContainerRef) {
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
const timelineContainerWidth = timelineContainerRef.current?.offsetWidth || 0;
const offset = Math.round(timelineContainerWidth / 2);
setTimelineOffset(offset);
}
@@ -180,7 +183,7 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
);
const onPlayPauseHandler = (isPlaying: boolean) => {
onPlayPause(isPlaying);
onPlayPause && onPlayPause(isPlaying);
};
const onPreviousHandler = () => {
@@ -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,11 +36,12 @@ 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>
);
}
return <div />
}, [timeline, onEventClick, firstBlockOffset]);
return timelineEventBlocks;
@@ -1,7 +1,7 @@
import { TimelineEvent } from './TimelineEvent';
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent: TimelineEvent;
timelineEvent?: TimelineEvent;
markerTime: Date;
seekComplete: boolean;
}
@@ -1,4 +1,4 @@
import { TimelineEvent } from './TimelineEvent';
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineEventBlock extends TimelineEvent {
index: number;
@@ -1,6 +1,6 @@
import { h } from 'preact';
import ActivityIndicator from '../ActivityIndicator';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('ActivityIndicator', () => {
test('renders an ActivityIndicator with default size md', async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { h } from 'preact';
import { DrawerProvider } from '../../context';
import AppBar from '../AppBar';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
import { useRef } from 'preact/hooks';
function Title() {
@@ -1,6 +1,6 @@
import { h } from 'preact';
import AutoUpdatingCameraImage from '../AutoUpdatingCameraImage';
import { screen, render } from '@testing-library/preact';
import { screen, render } from 'testing-library';
let mockOnload;
jest.mock('../CameraImage', () => {
@@ -34,9 +34,9 @@ describe('AutoUpdatingCameraImage', () => {
test('on load, sets a new cache key to search params', async () => {
dateNowSpy.mockReturnValueOnce(100).mockReturnValueOnce(200).mockReturnValueOnce(300);
render(<AutoUpdatingCameraImage camera="tacos" searchParams="foo" />);
render(<AutoUpdatingCameraImage camera="front" searchParams="foo" />);
mockOnload();
jest.runAllTimers();
expect(screen.queryByText('cache=100&foo')).toBeInTheDocument();
await screen.findByText('cache=100&foo');
expect(screen.getByText('cache=100&foo')).toBeInTheDocument();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Button from '../Button';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Button', () => {
test('renders children', async () => {
@@ -1,15 +1,10 @@
import { h } from 'preact';
import * as Api from '../../api';
import * as Hooks from '../../hooks';
import CameraImage from '../CameraImage';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('CameraImage', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => {
return { data: { cameras: { front: { name: 'front', detect: { width: 1280, height: 720 } } } } };
});
jest.spyOn(Api, 'useApiHost').mockReturnValue('http://base-url.local:5000');
jest.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
});
@@ -17,24 +12,4 @@ describe('CameraImage', () => {
render(<CameraImage camera="front" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('creates a scaled canvas using the available width & height, preserving camera aspect ratio', async () => {
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 720 }]);
render(<CameraImage camera="front" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
const canvas = screen.queryByTestId('cameraimage-canvas');
expect(canvas).toHaveAttribute('height', '405');
expect(canvas).toHaveAttribute('width', '720');
});
test('allows camera image to stretch to available space', async () => {
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 1400 }]);
render(<CameraImage camera="front" stretch />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
const canvas = screen.queryByTestId('cameraimage-canvas');
expect(canvas).toHaveAttribute('height', '787');
expect(canvas).toHaveAttribute('width', '1400');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Card from '../Card';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Card', () => {
test('renders a Card with media', async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Dialog from '../Dialog';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Dialog', () => {
let portal;
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Heading from '../Heading';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Heading', () => {
test('renders content with default size', async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Link from '../Link';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Link', () => {
test('renders a link', async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Menu, { MenuItem } from '../Menu';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
import { useRef } from 'preact/hooks';
describe('Menu', () => {
@@ -1,7 +1,7 @@
import { h } from 'preact';
import * as Context from '../../context';
import NavigationDrawer, { Destination } from '../NavigationDrawer';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('NavigationDrawer', () => {
let useDrawer, setShowDrawer;
@@ -49,6 +49,7 @@ describe('Destination', () => {
});
test('dismisses the drawer moments after being clicked', async () => {
jest.useFakeTimers();
render(
<NavigationDrawer>
<Destination href="/tacos" text="Tacos" />
+3 -3
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Prompt from '../Prompt';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('Prompt', () => {
let portal;
@@ -16,7 +16,7 @@ describe('Prompt', () => {
});
test('renders to a portal', async () => {
render(<Prompt title='Tacos' text='This is the dialog' />);
render(<Prompt title="Tacos" text="This is the dialog" />);
expect(screen.getByText('Tacos')).toBeInTheDocument();
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
});
@@ -29,7 +29,7 @@ describe('Prompt', () => {
{ color: 'red', text: 'Delete' },
{ text: 'Okay', onClick: handleClick },
]}
title='Tacos'
title="Tacos"
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
@@ -1,7 +1,7 @@
import { h, createRef } from 'preact';
import RelativeModal from '../RelativeModal';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('RelativeModal', () => {
test('keeps tab focus', async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Select from '../Select';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('Select', () => {
test('on focus, shows a menu', async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { h } from 'preact';
import Switch from '../Switch';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('Switch', () => {
test('renders a hidden checkbox', async () => {
@@ -1,6 +1,6 @@
import { h } from 'preact';
import TextField from '../TextField';
import { fireEvent, render, screen } from '@testing-library/preact';
import { render, screen, fireEvent } from 'testing-library';
describe('TextField', () => {
test('can render a leading icon', async () => {
@@ -20,20 +20,6 @@ describe('TextField', () => {
expect(icons[1]).toHaveAttribute('data-testid', 'icon-trailing');
});
test('focuses and blurs', async () => {
const handleFocus = jest.fn();
const handleBlur = jest.fn();
render(<TextField label="Tacos" onFocus={handleFocus} onBlur={handleBlur} />);
fireEvent.focus(screen.getByRole('textbox'));
expect(handleFocus).toHaveBeenCalled();
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(true);
fireEvent.blur(screen.getByRole('textbox'));
expect(handleBlur).toHaveBeenCalled();
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(false);
});
test('onChange updates the value', async () => {
const handleChangeText = jest.fn();
render(<TextField label="Tacos" onChangeText={handleChangeText} />);
@@ -1,6 +1,6 @@
import { h, createRef } from 'preact';
import Tooltip from '../Tooltip';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Tooltip', () => {
test('renders in a relative position', async () => {
+4 -3
View File
@@ -1,8 +1,9 @@
import { h } from 'preact';
import * as IDB from 'idb-keyval';
import { DarkModeProvider, useDarkMode, usePersistence } from '..';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
import { useCallback } from 'preact/hooks';
import * as Mqtt from '../../api/mqtt';
function DarkModeChecker() {
const { currentMode } = useDarkMode();
@@ -16,6 +17,8 @@ describe('DarkMode', () => {
get: jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined)),
set: jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true)),
};
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
});
test('uses media by default', async () => {
@@ -148,8 +151,6 @@ describe('usePersistence', () => {
my-default
</div>
`);
jest.runAllTimers();
});
test('updates with the previously-persisted value', async () => {
+1 -1
View File
@@ -1,2 +1,2 @@
export const ENV = import.meta.env.MODE;
export const API_HOST = import.meta.env.SNOWPACK_PUBLIC_API_HOST;
export const API_HOST = ENV === "production" ? "" : "http://localhost:5000";
+24
View 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
View 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);
+15 -4
View File
@@ -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>
);
}
+15 -4
View File
@@ -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
View 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 -1
View File
@@ -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 -1
View File
@@ -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" />
+15 -5
View File
@@ -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>
);
}
+15 -3
View File
@@ -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
View 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);
+2 -11
View File
@@ -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 */
}
/*
+2 -2
View File
@@ -1,6 +1,6 @@
import App from './App';
import { ApiProvider } from './api';
import { h, render } from 'preact';
import { render } from 'preact';
import 'preact/devtools';
import './index.css';
@@ -8,5 +8,5 @@ render(
<ApiProvider>
<App />
</ApiProvider>,
document.getElementById('root')
document.getElementById('app') as HTMLElement
);
+5
View File
@@ -0,0 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import JSX = preact.JSX
// temporary until codebase is converted to typescript
declare module "*";
+13 -6
View File
@@ -1,5 +1,6 @@
import { h, Fragment } from 'preact';
import AutoUpdatingCameraImage from '../components/AutoUpdatingCameraImage';
import ActivityIndicator from '../components/ActivityIndicator';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Button from '../components/Button';
import Card from '../components/Card';
@@ -10,18 +11,21 @@ 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 = cameraConfig
? Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
: 0;
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(
@@ -47,6 +51,10 @@ export default function Camera({ camera }) {
setShowSettings(!showSettings);
}, [showSettings, setShowSettings]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
const optionContent = showSettings ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Switch
@@ -92,8 +100,7 @@ export default function Camera({ camera }) {
</div>
</Fragment>
);
}
else if (viewMode === 'debug') {
} else if (viewMode === 'debug') {
player = (
<Fragment>
<div>
@@ -127,7 +134,7 @@ export default function Camera({ camera }) {
key={objectType}
header={objectType}
href={`/events?camera=${camera}&label=${objectType}`}
media={<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />}
media={<img src={`${apiHost}/api/${camera}/${objectType}/thumbnail.jpg`} />}
/>
))}
</div>
+13 -12
View File
@@ -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 -2
View File
@@ -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');
+12 -6
View File
@@ -6,30 +6,36 @@ 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).filter(([cam, conf]) => conf.gui.show).sort(([aCam, aConf], [bCam, bConf]) => aConf.gui.order === bConf.gui.order ? 0 : (aConf.gui.order > bConf.gui.order ? 1 : -1)).map(([camera, conf]) => (
{Object.entries(config.cameras)
.filter(([cam, conf]) => conf.gui.show)
.sort(([aCam, aConf], [bCam, bConf]) => aConf.gui.order === bConf.gui.order ? 0 : (aConf.gui.order > bConf.gui.order ? 1 : -1))
.map(([camera, 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(
() => [
+8 -8
View File
@@ -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 = {}, detection_fps: _, ...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>
-241
View File
@@ -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>
);
}
+395
View File
@@ -0,0 +1,395 @@
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 [state, setState] = useState({
showDownloadMenu: null,
showDatePicker: null,
showCalendar: null,
});
const [viewEvent, setViewEvent] = useState();
const [downloadEvent, setDownloadEvent] = useState({ id: null, has_clip: false, has_snapshot: false });
const eventsFetcher = useCallback((path, params) => {
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
return axios.get(path, { params }).then((res) => res.data);
}, []);
const getKey = useCallback(
(index, prevData) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = { ...searchParams, before: lastDate };
return ['events', pagedParams];
}
return ['events', searchParams];
},
[searchParams]
);
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
const { data: config } = useSWR('config');
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
zones: Object.values(config?.cameras || {})
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera?.zones || {}));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
labels: 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;
setState({ ...state, showDownloadMenu: true });
};
const handleSelectDateRange = useCallback(
(dates) => {
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
setState({ ...state, showDatePicker: false });
},
[searchParams, setSearchParams, state, setState]
);
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 (!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>
{filterValues.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>
{filterValues.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>
{filterValues.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={() => setState({ ...state, showDatePicker: true })}
/>
</div>
</div>
{state.showDownloadMenu && (
<Menu onDismiss={() => setState({ ...state, showDownloadMenu: 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>
)}
{state.showDatePicker && (
<Menu
className="rounded-t-none"
onDismiss={() => setState({ ...state, 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={() => {
setState({ ...state, showCalendar: true, showDatePicker: false });
}}
/>
</Menu>
)}
{state.showCalendar && (
<Menu
className="rounded-t-none"
onDismiss={() => setState({ ...state, showCalendar: false })}
relativeTo={datePicker}
>
<Calendar
onChange={handleSelectDateRange}
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
close={() => setState({ ...state, showCalendar: false })}
/>
</Menu>
)}
<div className="space-y-2">
{eventPages ? (
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>
);
});
})
) : (
<ActivityIndicator />
)}
</div>
<div>{isDone ? null : <ActivityIndicator />}</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More