swr events refactor

This commit is contained in:
Blake Blackshear 2022-02-26 13:11:00 -06:00
parent 4bae3993da
commit 1c9ba11e07
76 changed files with 29753 additions and 9109 deletions

View File

@ -9,19 +9,38 @@
"mhutchie.git-graph",
"ms-azuretools.vscode-docker",
"streetsidesoftware.code-spell-checker",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"ms-python.vscode-pylance"
"ms-python.vscode-pylance",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mikestead.dotenv",
"csstools.postcss",
"blanu.vscode-styled-jsx",
"bradlc.vscode-tailwindcss"
],
"settings": {
"python.pythonPath": "/usr/bin/python3",
"python.linting.pylintEnabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "black",
"python.languageServer": "Pylance",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true,
"terminal.integrated.shell.linux": "/bin/bash"
"eslint.workingDirectories": ["./web"],
"[json][jsonc]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsx][js][tsx][ts]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.codeActionsOnSave": [
"source.addMissingImports",
"source.fixAll.eslint"
]
},
"cSpell.ignoreWords": ["rtmp"],
"cSpell.words": ["preact"]
}
}

View File

@ -7,5 +7,6 @@ config/
.git
core
*.mp4
*.jpg
*.db
*.ts

View File

@ -20,6 +20,7 @@ services:
- .:/lab/frigate:cached
- ./config/config.yml:/config/config.yml:ro
- ./debug:/media/frigate
- /dev/bus/usb:/dev/bus/usb
ports:
- "1935:1935"
- "5000:5000"

View File

@ -6,7 +6,7 @@ ARG USER_GID=$USER_UID
# Create the user
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /bin/bash \
#
# [Optional] Add sudo support. Omit if you don't need to install software after connecting.
&& apt-get update \

View File

@ -173,7 +173,6 @@ http {
location /api/ {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header Cache-Control "no-store";
proxy_pass http://frigate_api/;
proxy_pass_request_headers on;
proxy_set_header Host $host;

View File

@ -183,8 +183,11 @@ def delete_event(id):
def event_thumbnail(id):
format = request.args.get("format", "ios")
thumbnail_bytes = None
event_complete = False
try:
event = Event.get(Event.id == id)
if not event.end_time is None:
event_complete = True
thumbnail_bytes = base64.b64decode(event.thumbnail)
except DoesNotExist:
# see if the object is currently being tracked
@ -219,6 +222,8 @@ def event_thumbnail(id):
response = make_response(thumbnail_bytes)
response.headers["Content-Type"] = "image/jpeg"
if event_complete:
response.headers["Cache-Control"] = "private, max-age=31536000"
return response
@ -305,9 +310,9 @@ def event_clip(id):
@bp.route("/events")
def events():
limit = request.args.get("limit", 100)
camera = request.args.get("camera")
label = request.args.get("label")
zone = request.args.get("zone")
camera = request.args.get("camera", "all")
label = request.args.get("label", "all")
zone = request.args.get("zone", "all")
after = request.args.get("after", type=float)
before = request.args.get("before", type=float)
has_clip = request.args.get("has_clip", type=int)
@ -317,20 +322,20 @@ def events():
clauses = []
excluded_fields = []
if camera:
if camera != "all":
clauses.append((Event.camera == camera))
if label:
if label != "all":
clauses.append((Event.label == label))
if zone:
if zone != "all":
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
if after:
clauses.append((Event.start_time >= after))
clauses.append((Event.start_time > after))
if before:
clauses.append((Event.start_time <= before))
clauses.append((Event.start_time < before))
if not has_clip is None:
clauses.append((Event.has_clip == has_clip))
@ -648,7 +653,7 @@ def recording_clip(camera, start_ts, end_ts):
"-safe",
"0",
"-i",
"-",
"/dev/stdin",
"-c",
"copy",
"-movflags",

View File

@ -1,2 +1,3 @@
build/*
node_modules/*
src/env.js

View File

@ -1,157 +1,39 @@
module.exports = {
parser: '@babel/eslint-parser',
extends: ['eslint:recommended', 'preact', 'prettier'],
plugins: ['react', 'jest'],
env: {
browser: true,
node: true,
mocha: true,
es6: true,
'jest/globals': true,
},
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
experimentalObjectRestSpread: true,
modules: true,
jsx: true,
},
},
extends: [
'prettier',
'preact',
'plugin:import/react',
'plugin:import/typescript',
'plugin:testing-library/recommended',
'plugin:jest/recommended',
],
plugins: ['import', 'testing-library', 'jest'],
env: {
es6: true,
node: true,
browser: true,
},
rules: {
'constructor-super': 'error',
'default-case': ['error', { commentPattern: '^no default$' }],
'handle-callback-err': ['error', '^(err|error)$'],
'new-cap': ['error', { newIsCap: true, capIsNew: false }],
'no-alert': 'error',
'no-array-constructor': 'error',
'no-caller': 'error',
'no-case-declarations': 'error',
'no-class-assign': 'error',
'no-cond-assign': 'error',
'no-console': 'error',
'no-const-assign': 'error',
'no-control-regex': 'error',
'no-debugger': 'error',
'no-delete-var': 'error',
'no-dupe-args': 'error',
'no-dupe-class-members': 'error',
'no-dupe-keys': 'error',
'no-duplicate-case': 'error',
'no-duplicate-imports': 'error',
'no-empty-character-class': 'error',
'no-empty-pattern': 'error',
'no-eval': 'error',
'no-ex-assign': 'error',
'no-extend-native': 'error',
'no-extra-bind': 'error',
'no-extra-boolean-cast': 'error',
'no-fallthrough': 'error',
'no-floating-decimal': 'error',
'no-func-assign': 'error',
'no-implied-eval': 'error',
'no-inner-declarations': ['error', 'functions'],
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'error',
'no-iterator': 'error',
'no-label-var': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-loop-func': 'error',
'no-multi-str': 'error',
'no-native-reassign': 'error',
'no-negated-in-lhs': 'error',
'no-new': 'error',
'no-new-func': 'error',
'no-new-object': 'error',
'no-new-require': 'error',
'no-new-symbol': 'error',
'no-new-wrappers': 'error',
'no-obj-calls': 'error',
'no-octal': 'error',
'no-octal-escape': 'error',
'no-path-concat': 'error',
'no-proto': 'error',
'no-redeclare': 'error',
'no-regex-spaces': 'error',
'no-return-assign': ['error', 'except-parens'],
'no-script-url': 'error',
'no-self-assign': 'error',
'no-self-compare': 'error',
'no-sequences': 'error',
'no-shadow-restricted-names': 'error',
'no-sparse-arrays': 'error',
'no-this-before-super': 'error',
'no-throw-literal': 'error',
'no-trailing-spaces': 'error',
'no-undef': 'error',
'no-undef-init': 'error',
'no-unexpected-multiline': 'error',
'no-unmodified-loop-condition': 'error',
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
'no-unreachable': 'error',
'no-unsafe-finally': 'error',
'no-unused-vars': ['error', { vars: 'all', args: 'none', ignoreRestSiblings: true }],
'no-useless-call': 'error',
'no-useless-computed-key': 'error',
'no-useless-concat': 'error',
'no-useless-constructor': 'error',
'no-useless-escape': 'error',
'no-var': 'error',
'no-with': 'error',
'prefer-const': 'error',
'prefer-rest-params': 'error',
'use-isnan': 'error',
'valid-typeof': 'error',
camelcase: 'off',
eqeqeq: ['error', 'allow-null'],
indent: ['error', 2, { SwitchCase: 1 }],
quotes: ['error', 'single', 'avoid-escape'],
radix: 'error',
yoda: ['error', 'never'],
'import/no-unresolved': 'error',
// 'react-hooks/exhaustive-deps': 'error',
'jest/consistent-test-it': ['error', { fn: 'test' }],
'jest/no-test-prefixes': 'error',
'jest/no-restricted-matchers': [
'error',
{ toMatchSnapshot: 'Use `toMatchInlineSnapshot()` and ensure you only snapshot very small elements' },
],
'jest/valid-describe': 'error',
'jest/valid-expect-in-promise': 'error',
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx'],
},
react: {
pragma: 'h',
},
},
globals: {
sleep: true,
},
rules: {
indent: ['error', 2, { SwitchCase: 1 }],
'comma-dangle': ['error', { objects: 'always-multiline', arrays: 'always-multiline' }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
overrides: [
{
files: ['*.{ts,tsx}'],
files: ['**/*.{ts,tsx}'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['plugin:@typescript-eslint/recommended'],
settings: {
'import/resolver': {
node: {
extensions: ['.ts', '.tsx'],
},
},
},
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
},
],
};

View File

@ -7,6 +7,6 @@ module.exports = {
testEnvironment: 'jsdom',
timers: 'fake',
moduleNameMapper: {
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js'
}
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js',
},
};

View File

@ -3,6 +3,7 @@
"target": "ES2019",
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
}
"jsxFragmentFactory": "Fragment"
},
"include": ["./src/**/*.js", "./src/**/*.jsx"]
}

36654
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,53 +2,57 @@
"name": "frigate",
"private": true,
"scripts": {
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
"start:custom": "snowpack dev",
"start": "SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
"prebuild": "rimraf build",
"build": "cross-env NODE_ENV=production SNOWPACK_MODE=production SNOWPACK_PUBLIC_API_HOST='' snowpack build",
"build": "SNOWPACK_PUBLIC_API_HOST='' NODE_ENV=production SNOWPACK_MODE=production snowpack build",
"lint": "npm run lint:cmd -- --fix",
"lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
"test": "jest"
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^5.0.1",
"axios": "^0.26.0",
"date-fns": "^2.21.3",
"idb-keyval": "^5.0.2",
"immer": "^9.0.6",
"preact": "^10.5.9",
"preact": "^10.6.6",
"preact-async-route": "^2.2.1",
"preact-router": "^3.2.1",
"preact-router": "^4.0.1",
"swr": "^1.2.2",
"video.js": "^7.15.4",
"videojs-playlist": "^4.3.1",
"videojs-seek-buttons": "^2.0.1"
},
"devDependencies": {
"@babel/eslint-parser": "^7.12.13",
"@babel/eslint-parser": "^7.17.0",
"@babel/plugin-transform-react-jsx": "^7.12.13",
"@babel/preset-env": "^7.12.13",
"@babel/preset-typescript": "^7.16.7",
"@prefresh/snowpack": "^3.0.1",
"@prefresh/snowpack": "^3.1.4",
"@snowpack/plugin-postcss": "^1.1.0",
"@snowpack/plugin-typescript": "^1.2.1",
"@tailwindcss/forms": "^0.4.0",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/preact": "^2.0.1",
"@testing-library/user-event": "^12.7.1",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"autoprefixer": "^10.2.1",
"autoprefixer": "^10.4.2",
"cross-env": "^7.0.3",
"eslint": "^7.19.0",
"eslint-config-preact": "^1.1.3",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.1.3",
"eslint-plugin-testing-library": "^3.10.1",
"eslint": "^8.10.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.1.1",
"eslint-plugin-testing-library": "^5.0.5",
"jest": "^26.6.3",
"postcss": "^8.2.10",
"postcss": "^8.4.7",
"postcss-cli": "^8.3.1",
"preact-cli": "^3.3.5",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"snowpack": "^3.0.11",
"snowpack-plugin-hash": "^0.14.2",
"tailwindcss": "^2.0.2"
"snowpack": "^3.8.8",
"snowpack-plugin-hash": "^0.16.0",
"tailwindcss": "^3.0.23"
}
}

View File

@ -1,3 +1,6 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')],
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -1,6 +1,4 @@
module.exports = {
printWidth: 120,
singleQuote: true,
jsxSingleQuote: true,
useTabs: false,
};

View File

@ -3,7 +3,10 @@ module.exports = {
public: { url: '/', static: true },
src: { url: '/dist' },
},
plugins: ['@snowpack/plugin-postcss', '@prefresh/snowpack', 'snowpack-plugin-hash'],
plugins: ['@prefresh/snowpack', '@snowpack/plugin-typescript', '@snowpack/plugin-postcss', 'snowpack-plugin-hash'],
devOptions: {
tailwindConfig: './tailwind.config.js',
},
routes: [{ match: 'all', src: '(?!.*(.svg|.gif|.json|.jpg|.jpeg|.png|.js)).*', dest: '/index.html' }],
optimize: {
bundle: false,
@ -11,9 +14,22 @@ module.exports = {
treeshake: true,
},
packageOptions: {
sourcemap: false,
knownEntrypoints: [
'@videojs/vhs-utils/es/stream.js',
'@videojs/vhs-utils/es/resolve-url.js',
'@videojs/vhs-utils/es/media-types.js',
'@videojs/vhs-utils/es/decode-b64-to-uint8-array.js',
'@videojs/vhs-utils/es/id3-helpers',
'@videojs/vhs-utils/es/byte-helpers',
'@videojs/vhs-utils/es/containers',
'@videojs/vhs-utils/es/codecs.js',
'global/window',
'global/document',
],
},
buildOptions: {
sourcemap: false,
buildOptions: {},
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
},
};

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>

View File

@ -19,7 +19,7 @@ export default function AppBar() {
const { send: sendRestart } = useRestart();
const handleSelectDarkMode = useCallback(
(value, label) => {
(value) => {
setDarkMode(value);
setShowMoreMenu(false);
},

View File

@ -3,12 +3,12 @@ import LinkedLogo from './components/LinkedLogo';
import { Match } from 'preact-router/match';
import { memo } from 'preact/compat';
import { ENV } from './env';
import { useConfig } from './api';
import useSWR from 'swr';
import { useMemo } from 'preact/hooks';
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
export default function Sidebar() {
const { data: config } = useConfig();
const { data: config } = useSWR('config');
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
const { birdseye } = config;
@ -21,7 +21,7 @@ export default function Sidebar() {
<Fragment>
<Separator />
{cameras.map(([camera]) => (
<Destination href={`/cameras/${camera}`} text={camera} />
<Destination key={camera} href={`/cameras/${camera}`} text={camera} />
))}
<Separator />
</Fragment>

View File

@ -37,7 +37,7 @@ describe('useFetch', () => {
beforeEach(() => {
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => {
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url) => {
if (url.endsWith('/api/config')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}

View File

@ -8,7 +8,9 @@ function Test() {
return state.__connected ? (
<div data-testid="data">
{Object.keys(state).map((key) => (
<div data-testid={key}>{JSON.stringify(state[key])}</div>
<div key={key} data-testid={key}>
{JSON.stringify(state[key])}
</div>
))}
</div>
) : null;
@ -28,10 +30,10 @@ describe('MqttProvider', () => {
return new Proxy(
{},
{
get(target, prop, receiver) {
get(_target, prop, _receiver) {
return wsClient[prop];
},
set(target, prop, value) {
set(_target, prop, value) {
wsClient[prop] = typeof value === 'function' ? jest.fn(value) : value;
if (prop === 'onopen') {
wsClient[prop]();
@ -121,12 +123,24 @@ describe('MqttProvider', () => {
</MqttProvider>
);
await screen.findByTestId('data');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"ON","retain":true}'
);
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
);
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"ON","retain":true}'
);
expect(screen.getByTestId('side/detect/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
);
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
);
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":true}'
);
});
});

View File

@ -1,166 +1,28 @@
import { h } from 'preact';
import { baseUrl } from './baseUrl';
import { h, createContext } from 'preact';
import useSWR, { SWRConfig } from 'swr';
import { MqttProvider } from './mqtt';
import produce from 'immer';
import { useContext, useEffect, useReducer } from 'preact/hooks';
import axios from 'axios';
export const FetchStatus = {
NONE: 'none',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'error',
};
const initialState = Object.freeze({
host: baseUrl,
queries: {},
});
const Api = createContext(initialState);
function reducer(state, { type, payload }) {
switch (type) {
case 'REQUEST': {
const { url, fetchId } = payload;
const data = state.queries[url]?.data || null;
return produce(state, (draftState) => {
draftState.queries[url] = { status: FetchStatus.LOADING, data, fetchId };
});
}
case 'RESPONSE': {
const { url, ok, data, fetchId } = payload;
return produce(state, (draftState) => {
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data, fetchId };
});
}
case 'DELETE': {
const { eventId } = payload;
return produce(state, (draftState) => {
Object.keys(draftState.queries).map((url) => {
draftState.queries[url].deletedId = eventId;
});
});
}
default:
return state;
}
}
axios.defaults.baseURL = `${baseUrl}/api/`;
export function ApiProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<Api.Provider value={{ state, dispatch }}>
<SWRConfig
value={{
fetcher: (path) => axios.get(path).then((res) => res.data),
}}
>
<MqttWithConfig>{children}</MqttWithConfig>
</Api.Provider>
</SWRConfig>
);
}
function MqttWithConfig({ children }) {
const { data, status } = useConfig();
return status === FetchStatus.LOADED ? <MqttProvider config={data}>{children}</MqttProvider> : children;
}
function shouldFetch(state, url, fetchId = null) {
if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
return true;
}
const { status } = state.queries[url];
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
}
export function useFetch(url, fetchId) {
const { state, dispatch } = useContext(Api);
useEffect(() => {
if (!shouldFetch(state, url, fetchId)) {
return;
}
async function fetchData() {
await dispatch({ type: 'REQUEST', payload: { url, fetchId } });
const response = await fetch(`${state.host}${url}`);
try {
const data = await response.json();
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data, fetchId } });
} catch (e) {
await dispatch({ type: 'RESPONSE', payload: { url, ok: false, data: null, fetchId } });
}
}
fetchData();
}, [url, fetchId, state, dispatch]);
if (!(url in state.queries)) {
return { data: null, status: FetchStatus.NONE };
}
const data = state.queries[url].data || null;
const status = state.queries[url].status;
const deletedId = state.queries[url].deletedId || 0;
return { data, status, deletedId };
}
export function useDelete() {
const { dispatch, state } = useContext(Api);
async function deleteEvent(eventId) {
if (!eventId) return null;
const response = await fetch(`${state.host}/api/events/${eventId}`, { method: 'DELETE' });
await dispatch({ type: 'DELETE', payload: { eventId } });
return await (response.status < 300 ? response.json() : { success: true });
}
return deleteEvent;
}
export function useRetain() {
const { state } = useContext(Api);
async function retainEvent(eventId, shouldRetain) {
if (!eventId) return null;
if (shouldRetain) {
const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'POST' });
return await (response.status < 300 ? response.json() : { success: true });
} else {
const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'DELETE' });
return await (response.status < 300 ? response.json() : { success: true });
}
}
return retainEvent;
const { data } = useSWR('config');
return data ? <MqttProvider config={data}>{children}</MqttProvider> : children;
}
export function useApiHost() {
const { state } = useContext(Api);
return state.host;
}
export function useEvents(searchParams, fetchId) {
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, fetchId);
}
export function useEvent(eventId, fetchId) {
const url = `/api/events/${eventId}`;
return useFetch(url, fetchId);
}
export function useRecording(camera, fetchId) {
const url = `/api/${camera}/recordings`;
return useFetch(url, fetchId);
}
export function useConfig(searchParams, fetchId) {
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, fetchId);
}
export function useStats(searchParams, fetchId) {
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, fetchId);
return baseUrl;
}

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);
}, []);

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

View File

@ -5,7 +5,7 @@ import ArrowRightDouble from '../icons/ArrowRightDouble';
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
const Calender = ({ onChange, calenderRef, close }) => {
const Calendar = ({ onChange, calendarRef, close }) => {
const keyRef = useRef([]);
const date = new Date();
@ -159,8 +159,8 @@ const Calender = ({ onChange, calenderRef, close }) => {
after,
before:
day.timestamp >= todayTimestamp
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
};
}
@ -243,26 +243,26 @@ const Calender = ({ onChange, calenderRef, close }) => {
const days =
state.monthDetails &&
state.monthDetails.map((day, idx) => {
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
});
return (
@ -280,7 +280,7 @@ const Calender = ({ onChange, calenderRef, close }) => {
};
return (
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
<div className="select-none w-96 flex flex-shrink" ref={calendarRef}>
<div className="py-4 px-6">
<div className="flex items-center">
<div className="w-1/6 relative flex justify-around">
@ -326,4 +326,4 @@ const Calender = ({ onChange, calenderRef, close }) => {
);
};
export default Calender;
export default Calendar;

View File

@ -1,11 +1,12 @@
import { h } from 'preact';
import ActivityIndicator from './ActivityIndicator';
import { useApiHost, useConfig } from '../api';
import { useApiHost } from '../api';
import useSWR from 'swr';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useResizeObserver } from '../hooks';
export default function CameraImage({ camera, onload, searchParams = '', stretch = false }) {
const { data: config } = useConfig();
const { data: config } = useSWR('config');
const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef(null);

View File

@ -66,7 +66,6 @@ export default function DatePicker({
onBlur,
onChangeText,
onFocus,
readonly,
trailingIcon: TrailingIcon,
value: propValue = '',
...props

View File

@ -1,6 +1,6 @@
import { h } from 'preact';
import Heading from '../Heading';
import { TimelineEvent } from '../Timeline/Timeline';
import type { TimelineEvent } from '../Timeline/TimelineEvent';
interface HistoryHeaderProps {
event: TimelineEvent;

View File

@ -1,17 +1,29 @@
import { Fragment, h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { useEvents } from '../../api';
import { useSearchString } from '../../hooks/useSearchString';
import { getNowYesterdayInLong } from '../../utils/dateUtil';
import useSWR from 'swr';
import axios from 'axios';
import Timeline from '../Timeline/Timeline';
import { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
import { TimelineEvent } from '../Timeline/TimelineEvent';
import type { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
import type { TimelineEvent } from '../Timeline/TimelineEvent';
import { HistoryHeader } from './HistoryHeader';
import { HistoryVideo } from './HistoryVideo';
export default function HistoryViewer({ camera }) {
const { searchString } = useSearchString(500, `camera=${camera}&after=${getNowYesterdayInLong()}`);
const { data: events } = useEvents(searchString);
const searchParams = {
before: null,
after: null,
camera,
label: 'all',
zone: 'all',
};
// TODO: refactor
const eventsFetcher = (path, params) => {
params = { ...params, include_thumbnails: 0, limit: 500 };
return axios.get(path, { params }).then((res) => res.data);
};
const { data: events } = useSWR(['events', searchParams], eventsFetcher);
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);

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

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

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

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

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 ${

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>

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

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[];

View File

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

View File

@ -3,7 +3,7 @@ import { useMemo } from 'preact/hooks';
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
import { convertRemToPixels } from '../../utils/windowUtils';
import { TimelineBlockView } from './TimelineBlockView';
import { TimelineEventBlock } from './TimelineEventBlock';
import type { TimelineEventBlock } from './TimelineEventBlock';
interface TimelineBlocksProps {
timeline: TimelineEventBlock[];
@ -36,7 +36,7 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
...block,
yOffset: block.yOffset + timelineBlockOffset,
};
return <TimelineBlockView block={updatedBlock} onClick={onClickHandler} />;
return <TimelineBlockView key={block.id} block={updatedBlock} onClick={onClickHandler} />;
})}
</div>
);

View File

@ -1,4 +1,4 @@
import { TimelineEvent } from './TimelineEvent';
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent: TimelineEvent;

View File

@ -1,4 +1,4 @@
import { TimelineEvent } from './TimelineEvent';
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineEventBlock extends TimelineEvent {
index: number;

View File

@ -6,14 +6,14 @@ describe('Tooltip', () => {
test('renders in a relative position', async () => {
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 100,
y: 100,
width: 50,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 40, height: 15 });
const ref = createRef();
@ -34,14 +34,14 @@ describe('Tooltip', () => {
window.innerWidth = 1024;
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 1000,
y: 100,
width: 24,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
@ -61,14 +61,14 @@ describe('Tooltip', () => {
test('if too far left, renders to the right', async () => {
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 0,
y: 100,
width: 24,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
@ -89,14 +89,14 @@ describe('Tooltip', () => {
window.scrollY = 90;
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 100,
y: 100,
width: 24,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();

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
web/src/icons/Camera.jsx Normal file
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);

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

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

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

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

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

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

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
web/src/icons/Zone.jsx Normal file
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);

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

View File

@ -10,18 +10,19 @@ import Switch from '../components/Switch';
import ButtonsTabbed from '../components/ButtonsTabbed';
import { usePersistence } from '../context';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useApiHost, useConfig } from '../api';
import { useApiHost } from '../api';
import useSWR from 'swr';
const emptyObject = Object.freeze({});
export default function Camera({ camera }) {
const { data: config } = useConfig();
const { data: config } = useSWR('config');
const apiHost = useApiHost();
const [showSettings, setShowSettings] = useState(false);
const [viewMode, setViewMode] = useState('live');
const cameraConfig = config?.cameras[camera];
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(

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}

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

View File

@ -6,30 +6,33 @@ import ClipIcon from '../icons/Clip';
import MotionIcon from '../icons/Motion';
import SnapshotIcon from '../icons/Snapshot';
import { useDetectState, useRecordingsState, useSnapshotsState } from '../api/mqtt';
import { useConfig, FetchStatus } from '../api';
import { useMemo } from 'preact/hooks';
import useSWR from 'swr';
export default function Cameras() {
const { data: config, status } = useConfig();
const { data: config } = useSWR('config');
return status !== FetchStatus.LOADED ? (
return !config ? (
<ActivityIndicator />
) : (
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
{Object.entries(config.cameras).map(([camera, conf]) => (
<Camera name={camera} conf={conf} />
<Camera key={camera} name={camera} conf={conf} />
))}
</div>
);
}
function Camera({ name, conf }) {
function Camera({ name }) {
const { payload: detectValue, send: sendDetect } = useDetectState(name);
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const href = `/cameras/${name}`;
const buttons = useMemo(() => {
return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }];
return [
{ name: 'Events', href: `/events?camera=${name}` },
{ name: 'Recordings', href: `/recording/${name}` },
];
}, [name]);
const icons = useMemo(
() => [

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 = {}, ...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>

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

375
web/src/routes/Events.jsx Normal file
View File

@ -0,0 +1,375 @@
import { h, Fragment } from 'preact';
import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import { useApiHost } from '../api';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import axios from 'axios';
import { useState, useRef, useCallback, useMemo } from 'preact/hooks';
import VideoPlayer from '../components/VideoPlayer';
import { StarRecording } from '../icons/StarRecording';
import { Snapshot } from '../icons/Snapshot';
import { Clip } from '../icons/Clip';
import { Zone } from '../icons/Zone';
import { Camera } from '../icons/Camera';
import { Delete } from '../icons/Delete';
import { Download } from '../icons/Download';
import Menu, { MenuItem } from '../components/Menu';
import CalendarIcon from '../icons/Calendar';
import Calendar from '../components/Calendar';
const API_LIMIT = 25;
const daysAgo = (num) => {
let date = new Date();
date.setDate(date.getDate() - num);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
};
const monthsAgo = (num) => {
let date = new Date();
date.setMonth(date.getMonth() - num);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
};
export default function Events({ path, ...props }) {
const apiHost = useApiHost();
const [searchParams, setSearchParams] = useState({
before: null,
after: null,
camera: props.camera ?? 'all',
label: props.label ?? 'all',
zone: props.zone ?? 'all',
});
const [viewEvent, setViewEvent] = useState();
const [downloadEvent, setDownloadEvent] = useState({ id: null, has_clip: false, has_snapshot: false });
const [showDownloadMenu, setShowDownloadMenu] = useState();
const [showDatePicker, setShowDatePicker] = useState();
const [showCalendar, setShowCalendar] = useState();
const eventsFetcher = (path, params) => {
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
return axios.get(path, { params }).then((res) => res.data);
};
const getKey = (index, prevData) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = { ...searchParams, before: lastDate };
return ['events', pagedParams];
}
return ['events', searchParams];
};
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
const { data: config } = useSWR('config');
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
const zones = useMemo(
() =>
Object.values(config.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[config]
);
const labels = useMemo(() => {
return Object.values(config.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, config.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [config]);
const onSave = async (e, eventId, save) => {
e.stopPropagation();
let response;
if (save) {
response = await axios.post(`events/${eventId}/retain`);
} else {
response = await axios.delete(`events/${eventId}/retain`);
}
if (response.status === 200) {
mutate();
}
};
const onDelete = async (e, eventId) => {
e.stopPropagation();
const response = await axios.delete(`events/${eventId}`);
if (response.status === 200) {
mutate();
}
};
const datePicker = useRef();
const downloadButton = useRef();
const onDownloadClick = (e, event) => {
e.stopPropagation();
setDownloadEvent((_prev) => ({ id: event.id, has_clip: event.has_clip, has_snapshot: event.has_snapshot }));
downloadButton.current = e.target;
setShowDownloadMenu(true);
};
const handleSelectDateRange = useCallback(
(dates) => {
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
setShowDatePicker(false);
},
[searchParams, setSearchParams, setShowDatePicker]
);
const onFilter = useCallback(
(name, value) => {
const updatedParams = { ...searchParams, [name]: value };
setSearchParams(updatedParams);
const queryString = Object.keys(updatedParams)
.map((key) => {
if (updatedParams[key] && updatedParams[key] != 'all') {
return `${key}=${updatedParams[key]}`;
}
return null;
})
.filter((val) => val)
.join('&');
route(`${path}?${queryString}`);
},
[path, searchParams, setSearchParams]
);
const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT;
// hooks for infinite scroll
const observer = useRef();
const lastEventRef = useCallback(
(node) => {
if (isValidating) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) observer.current.observe(node);
},
[size, setSize, isValidating, isDone]
);
if (!eventPages || !config) {
return <ActivityIndicator />;
}
return (
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Events</Heading>
<div className="flex flex-wrap gap-2 items-center">
<select
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
value={searchParams.camera}
onChange={(e) => onFilter('camera', e.target.value)}
>
<option value="all">all</option>
{cameras.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
value={searchParams.label}
onChange={(e) => onFilter('label', e.target.value)}
>
<option value="all">all</option>
{labels.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
value={searchParams.zone}
onChange={(e) => onFilter('zone', e.target.value)}
>
<option value="all">all</option>
{zones.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<div ref={datePicker} className="ml-auto">
<CalendarIcon className="h-8 w-8 cursor-pointer" onClick={() => setShowDatePicker(true)} />
</div>
</div>
{showDownloadMenu && (
<Menu onDismiss={() => setShowDownloadMenu(false)} relativeTo={downloadButton}>
{downloadEvent.has_snapshot && (
<MenuItem
icon={Snapshot}
label="Download Snapshot"
value="snapshot"
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
download
/>
)}
{downloadEvent.has_clip && (
<MenuItem
icon={Clip}
label="Download Clip"
value="clip"
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
download
/>
)}
</Menu>
)}
{showDatePicker && (
<Menu className="rounded-t-none" onDismiss={() => setShowDatePicker(false)} relativeTo={datePicker}>
<MenuItem label="All" value={{ before: null, after: null }} onSelect={handleSelectDateRange} />
<MenuItem label="Today" value={{ before: null, after: daysAgo(0) }} onSelect={handleSelectDateRange} />
<MenuItem
label="Yesterday"
value={{ before: daysAgo(0), after: daysAgo(1) }}
onSelect={handleSelectDateRange}
/>
<MenuItem label="Last 7 Days" value={{ before: null, after: daysAgo(7) }} onSelect={handleSelectDateRange} />
<MenuItem label="This Month" value={{ before: null, after: monthsAgo(0) }} onSelect={handleSelectDateRange} />
<MenuItem
label="Last Month"
value={{ before: monthsAgo(0), after: monthsAgo(1) }}
onSelect={handleSelectDateRange}
/>
<MenuItem
label="Custom Range"
value="custom"
onSelect={() => {
setShowCalendar(true);
setShowDatePicker(false);
}}
/>
</Menu>
)}
{showCalendar && (
<Menu className="rounded-t-none" onDismiss={() => setShowCalendar(false)} relativeTo={datePicker}>
<Calendar onChange={handleSelectDateRange} close={() => setShowCalendar(false)} />
</Menu>
)}
<div className="space-y-2">
{eventPages.map((page, i) => {
const lastPage = eventPages.length === i + 1;
return page.map((event, j) => {
const lastEvent = lastPage && page.length === j + 1;
return (
<Fragment key={event.id}>
<div
ref={lastEvent ? lastEventRef : false}
className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
>
<div
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain"
style={{
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
}}
>
<StarRecording
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
fill={event.retain_indefinitely ? 'currentColor' : 'none'}
/>
{event.end_time ? null : (
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
In progress
</div>
)}
</div>
<div className="m-2 flex grow">
<div className="flex flex-col grow">
<div className="capitalize text-lg font-bold">
{event.label} ({(event.top_score * 100).toFixed(0)}%)
</div>
<div className="text-sm">
{new Date(event.start_time * 1000).toLocaleDateString()}{' '}
{new Date(event.start_time * 1000).toLocaleTimeString()}
</div>
<div className="capitalize text-sm flex align-center mt-1">
<Camera className="h-5 w-5 mr-2 inline" />
{event.camera}
</div>
<div className="capitalize text-sm flex align-center">
<Zone className="w-5 h-5 mr-2 inline" />
{event.zones.join(',')}
</div>
</div>
<div class="flex flex-col">
<Delete className="cursor-pointer" stroke="#f87171" onClick={(e) => onDelete(e, event.id)} />
<Download
className="h-6 w-6 mt-auto"
stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
onClick={(e) => onDownloadClick(e, event)}
/>
</div>
</div>
</div>
{viewEvent !== event.id ? null : (
<div className="space-y-4">
<div className="mx-auto">
{event.has_clip ? (
<>
<Heading size="lg">Clip</Heading>
<VideoPlayer
options={{
preload: 'auto',
autoplay: true,
sources: [
{
src: `${apiHost}/vod/event/${event.id}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
}}
seekOptions={{ forward: 10, back: 5 }}
onReady={() => {}}
/>
</>
) : (
<div className="flex justify-center">
<div>
<Heading size="sm">{event.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
<img
className="flex-grow-0"
src={
event.has_snapshot
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
: `data:image/jpeg;base64,${event.thumbnail}`
}
alt={`${event.label} at ${(event.top_score * 100).toFixed(0)}% confidence`}
/>
</div>
</div>
)}
</div>
</div>
)}
</Fragment>
);
});
})}
</div>
<div>{isDone ? null : <ActivityIndicator />}</div>
</div>
);
}

View File

@ -1,26 +0,0 @@
import { h } from 'preact';
import Select from '../../../components/Select';
import { useCallback } from 'preact/hooks';
function Filter({ onChange, searchParams, paramName, options, ...rest }) {
const handleSelect = useCallback(
(key) => {
const newParams = new URLSearchParams(searchParams.toString());
Object.keys(key).map((entries) => {
if (key[entries] !== 'all') {
newParams.set(entries, key[entries]);
} else {
paramName.map((p) => newParams.delete(p));
}
});
onChange(newParams);
},
[searchParams, paramName, onChange]
);
const obj = {};
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
}
export default Filter;

View File

@ -1,38 +0,0 @@
import { h } from 'preact';
import { useCallback, useMemo } from 'preact/hooks';
import Link from '../../../components/Link';
import { route } from 'preact-router';
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
// searchParams.delete('before');
}, []);
const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
removeDefaultSearchKeys(params);
return `${pathname}?${params.toString()}`;
}, [searchParams, paramName, pathname, name, removeDefaultSearchKeys]);
const handleClick = useCallback(
(event) => {
event.preventDefault();
route(href, true);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
onFilter(params);
},
[href, searchParams, onFilter, paramName, name]
);
return (
<Link href={href} onclick={handleClick}>
{name}
</Link>
);
}
export default Filterable;

View File

@ -1,81 +0,0 @@
import { h } from 'preact';
import Filter from './filter';
import { useConfig } from '../../../api';
import { useMemo, useState } from 'preact/hooks';
import { DateFilterOptions } from '../../../components/DatePicker';
import Button from '../../../components/Button';
const Filters = ({ onChange, searchParams }) => {
const [viewFilters, setViewFilters] = useState(false);
const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo(
() =>
Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[data]
);
const labels = useMemo(() => {
return Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [data]);
return (
<div>
<Button
onClick={() => setViewFilters(!viewFilters)}
className="block xs:hidden w-full mb-4 text-center"
type="text"
>
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
</Button>
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...cameras]}
paramName={['camera']}
label="Camera"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...zones]}
paramName={['zone']}
label="Zone"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...labels]}
paramName={['label']}
label="Label"
searchParams={searchParams}
/>
<Filter
type="datepicker"
onChange={onChange}
options={DateFilterOptions}
paramName={['before', 'after']}
label="DatePicker"
searchParams={searchParams}
/>
</div>
</div>
);
};
export default Filters;

View File

@ -1,3 +0,0 @@
export { default as TableHead } from './tableHead';
export { default as TableRow } from './tableRow';
export { default as Filters } from './filters';

View File

@ -1,19 +0,0 @@
import { h } from 'preact';
import { Thead, Th, Tr } from '../../../components/Table';
const TableHead = () => (
<Thead>
<Tr>
<Th />
<Th>Camera</Th>
<Th>Label</Th>
<Th>Score</Th>
<Th>Zones</Th>
<Th>Retain</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
);
export default TableHead;

View File

@ -1,121 +0,0 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useState, useMemo } from 'preact/hooks';
import { Tr, Td, Tbody } from '../../../components/Table';
import Filterable from './filterable';
import Event from '../../Event';
import { useSearchString } from '../../../hooks/useSearchString';
import { useClickOutside } from '../../../hooks/useClickOutside';
const EventsRow = memo(
({
id,
apiHost,
start_time: startTime,
end_time: endTime,
scrollToRef,
lastRowRef,
handleFilter,
pathname,
limit,
camera,
label,
top_score: score,
zones,
retain_indefinitely
}) => {
const [viewEvent, setViewEvent] = useState(null);
const { searchString, removeDefaultSearchKeys } = useSearchString(limit);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
const innerRef = useClickOutside(() => {
setViewEvent(null);
});
const viewEventHandler = useCallback(
(id) => {
//Toggle event view
if (viewEvent === id) return setViewEvent(null);
//Set event id to be rendered.
setViewEvent(id);
},
[viewEvent]
);
const start = new Date(parseInt(startTime * 1000, 10));
const end = endTime ? new Date(parseInt(endTime * 1000, 10)) : null;
return (
<Tbody reference={innerRef}>
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
<Td className="w-40">
<a
onClick={() => viewEventHandler(id)}
ref={lastRowRef}
data-start-time={startTime}
// data-reached-end={reachedEnd} <-- Enable this will cause all events to re-render when reaching end.
>
<img
width="150"
height="150"
className="cursor-pointer"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="camera"
name={camera}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="label"
name={label}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</Td>
<Td>{(score * 100).toFixed(2)}%</Td>
<Td>
<ul>
{zones.map((zone) => (
<li>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchString}
paramName="zone"
name={zone}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</li>
))}
</ul>
</Td>
<Td>{retain_indefinitely ? 'True' : 'False'}</Td>
<Td>{start.toLocaleDateString()}</Td>
<Td>{start.toLocaleTimeString()}</Td>
<Td>{end === null ? 'In progress' : end.toLocaleTimeString()}</Td>
</Tr>
{viewEvent === id ? (
<Tr className="border-b-1">
<Td colSpan="8" reference={(el) => (scrollToRef[id] = el)}>
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
</Td>
</Tr>
) : null}
</Tbody>
);
}
);
export default EventsRow;

View File

@ -1,107 +0,0 @@
import { h } from 'preact';
import ActivityIndicator from '../../components/ActivityIndicator';
import Heading from '../../components/Heading';
import { TableHead, Filters, TableRow } from './components';
import { route } from 'preact-router';
import { FetchStatus, useApiHost, useEvents } from '../../api';
import { Table, Tfoot, Tr, Td } from '../../components/Table';
import { useCallback, useEffect, useMemo, useReducer } from 'preact/hooks';
import { reducer, initialState } from './reducer';
import { useSearchString } from '../../hooks/useSearchString';
import { useIntersectionObserver } from '../../hooks';
const API_LIMIT = 25;
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
const apiHost = useApiHost();
const { searchString, setSearchString, removeDefaultSearchKeys } = useSearchString(limit);
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
const { data, status, deletedId } = useEvents(searchString);
const scrollToRef = useMemo(() => Object, []);
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (data && Array.isArray(data) && data.length + deleted < limit) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
if (deletedId) {
dispatch({ type: 'DELETE_EVENT', deletedId });
}
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
const [entry, setIntersectNode] = useIntersectionObserver();
useEffect(() => {
if (entry && entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
setSearchString(limit, searchParams.toString());
}
}, [entry, limit, setSearchString]);
const lastCellRef = useCallback(
(node) => {
if (node !== null && !reachedEnd) {
setIntersectNode(node);
}
},
[setIntersectNode, reachedEnd]
);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(limit, searchParams.toString());
route(`${pathname}?${searchParams.toString()}`);
},
[limit, pathname, setSearchString, removeDefaultSearchKeys]
);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
const RenderTableRow = useCallback(
(props) => (
<TableRow
key={props.id}
apiHost={apiHost}
scrollToRef={scrollToRef}
pathname={pathname}
limit={API_LIMIT}
handleFilter={handleFilter}
{...props}
/>
),
[apiHost, handleFilter, pathname, scrollToRef]
);
return (
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Events</Heading>
<Filters onChange={handleFilter} searchParams={searchParams} />
<div className="min-w-0 overflow-auto">
<Table className="min-w-full table-fixed">
<TableHead />
{events.map((props, idx) => {
const lastRowRef = idx === events.length - 1 ? lastCellRef : undefined;
return <RenderTableRow {...props} lastRowRef={lastRowRef} idx={idx} />;
})}
<Tfoot>
<Tr>
<Td className="text-center p-4" colSpan="8">
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
</Td>
</Tr>
</Tfoot>
</Table>
</div>
</div>
);
}

View File

@ -1,47 +0,0 @@
import produce from 'immer';
export const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
export const reducer = (state = initialState, action) => {
switch (action.type) {
case 'DELETE_EVENT': {
const { deletedId } = action;
return produce(state, (draftState) => {
const idx = draftState.events.findIndex((e) => e.id === deletedId);
if (idx === -1) return state;
draftState.events.splice(idx, 1);
draftState.deleted++;
});
}
case 'APPEND_EVENTS': {
const {
meta: { searchString },
payload,
} = action;
return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true;
draftState.events.push(...payload);
draftState.deleted = 0;
});
}
case 'REACHED_END': {
const {
meta: { searchString },
} = action;
return produce(state, (draftState) => {
draftState.reachedEnd = true;
draftState.searchStrings[searchString] = true;
});
}
case 'RESET':
return initialState;
default:
return state;
}
};

View File

@ -4,13 +4,14 @@ import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import RecordingPlaylist from '../components/RecordingPlaylist';
import VideoPlayer from '../components/VideoPlayer';
import { FetchStatus, useApiHost, useRecording } from '../api';
import { useApiHost } from '../api';
import useSWR from 'swr';
export default function Recording({ camera, date, hour, seconds }) {
const apiHost = useApiHost();
const { data, status } = useRecording(camera);
const { data } = useSWR(`${camera}/recordings`);
if (status !== FetchStatus.LOADED) {
if (!data) {
return <ActivityIndicator />;
}

View File

@ -1,68 +0,0 @@
import { h } from 'preact';
import * as Api from '../../api';
import Event from '../Event';
import { render, screen } from '@testing-library/preact';
describe('Event Route', () => {
let useEventMock;
beforeEach(() => {
useEventMock = jest.spyOn(Api, 'useEvent').mockImplementation(() => ({
data: mockEvent,
status: 'loaded',
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://localhost:5000');
});
test('shows an ActivityIndicator if not yet loaded', async () => {
useEventMock.mockReturnValueOnce(() => ({ status: 'loading' }));
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('shows cameras', async () => {
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
expect(screen.queryByText('Clip')).toBeInTheDocument();
expect(screen.queryByLabelText('Video Player')).toBeInTheDocument();
expect(screen.queryByText('Best Image')).not.toBeInTheDocument();
expect(screen.queryByText('Thumbnail')).not.toBeInTheDocument();
});
test('does not render a video if there is no clip', async () => {
useEventMock.mockReturnValue({ data: { ...mockEvent, has_clip: false }, status: 'loaded' });
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByText('Clip')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Video Player')).not.toBeInTheDocument();
expect(screen.queryByText('Best Image')).toBeInTheDocument();
expect(screen.queryByText('Thumbnail')).not.toBeInTheDocument();
});
test('shows the thumbnail if no snapshot available', async () => {
useEventMock.mockReturnValue({ data: { ...mockEvent, has_clip: false, has_snapshot: false }, status: 'loaded' });
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByText('Best Image')).not.toBeInTheDocument();
expect(screen.queryByText('Thumbnail')).toBeInTheDocument();
expect(screen.queryByAltText('person at 82.0% confidence')).toHaveAttribute(
'src',
'data:image/jpeg;base64,/9j/4aa...'
);
});
});
const mockEvent = {
camera: 'front',
end_time: 1613257337.841237,
has_clip: true,
has_snapshot: true,
id: '1613257326.237365-83cgl2',
label: 'person',
start_time: 1613257326.237365,
top_score: 0.8203125,
zones: ['front_patio'],
thumbnail: '/9j/4aa...',
};

View File

@ -1,44 +1,39 @@
export async function getCameraMap(url, cb, props) {
export async function getCameraMap(_url, _cb, _props) {
const module = await import('./CameraMap.jsx');
return module.default;
}
export async function getCamera(url, cb, props) {
export async function getCamera(_url, _cb, _props) {
const module = await import('./Camera.jsx');
return module.default;
}
export async function getCameraV2(url, cb, props) {
export async function getCameraV2(_url, _cb, _props) {
const module = await import('./Camera_V2.jsx');
return module.default;
}
export async function getEvent(url, cb, props) {
const module = await import('./Event.jsx');
return module.default;
}
export async function getBirdseye(url, cb, props) {
export async function getBirdseye(_url, _cb, _props) {
const module = await import('./Birdseye.jsx');
return module.default;
}
export async function getEvents(url, cb, props) {
const module = await import('./Events');
export async function getEvents(_url, _cb, _props) {
const module = await import('./Events.jsx');
return module.default;
}
export async function getRecording(url, cb, props) {
export async function getRecording(_url, _cb, _props) {
const module = await import('./Recording.jsx');
return module.default;
}
export async function getDebug(url, cb, props) {
export async function getDebug(_url, _cb, _props) {
const module = await import('./Debug.jsx');
return module.default;
}
export async function getStyleGuide(url, cb, props) {
export async function getStyleGuide(_url, _cb, _props) {
const module = await import('./StyleGuide.jsx');
return module.default;
}

View File

@ -1,5 +1,5 @@
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
import { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
import type { TimelineEvent } from '../../components/Timeline/TimelineEvent';
import type { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
import { epochToLong, longToDate } from '../dateUtil';
export const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
@ -51,15 +51,15 @@ export const getTimelineEventBlocksFromTimelineEvents = (events: TimelineEvent[]
return rows;
})
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}
};
export const findLargestYOffsetInBlocks = (blocks: TimelineEventBlock[]): number => {
return blocks.reduce((largestYOffset, current) => {
if (current.yOffset > largestYOffset) {
return current.yOffset
return current.yOffset;
}
return largestYOffset;
}, 0)
}, 0);
};
export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset: number): number => {
@ -68,6 +68,6 @@ export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset:
const startTimeEpoch = firstBlock.startTime.getTime();
const endTimeEpoch = Date.now();
const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch);
return timelineDurationLong + offset * 2
return timelineDurationLong + offset * 2;
}
}
};

View File

@ -5,11 +5,11 @@ export const dateToLong = (date: Date): number => epochToLong(date.getTime());
const getDateTimeYesterday = (dateTime: Date): Date => {
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
}
};
const getNowYesterday = (): Date => {
return getDateTimeYesterday(new Date());
}
};
export const getNowYesterdayInLong = (): number => {
return dateToLong(getNowYesterday());

View File

@ -1,4 +1,4 @@
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
import type { TimelineEvent } from '../../components/Timeline/TimelineEvent';
export const getColorFromTimelineEvent = (event: TimelineEvent) => {
const { label } = event;

View File

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

View File

@ -1,5 +1,9 @@
module.exports = {
purge: ['./public/**/*.html', './src/**/*.{jsx,tsx}', './src/utils/tailwind/*.{jsx,tsx,js,ts}'],
mode: 'jit',
content: [
"./public/**/*.html",
"./src/**/*.{js,jsx,ts,tsx}",
],
darkMode: 'class',
theme: {
extend: {
@ -20,8 +24,7 @@ module.exports = {
none: '',
},
},
variants: {
extend: {},
},
plugins: [],
plugins: [
require('@tailwindcss/forms'),
],
};

View File

@ -1,15 +1,28 @@
{
"include": ["./src/**/*.tsx", "./src/**/*.ts"],
"compilerOptions": {
"module": "CommonJS",
"target": "ES2019",
"jsx": "react",
"module": "esnext",
"target": "esnext",
"moduleResolution": "node",
"jsx": "preserve",
"jsxFactory": "h",
"lib": [
"ES2019"
]
},
"include": [
"./src/**/*.tsx",
"./src/**/*.ts"
]
}
"baseUrl": "./",
/* paths - import rewriting/resolving */
"paths": {
// If you configured any Snowpack aliases, add them here.
// Add this line to get types for streaming imports (packageOptions.source="remote"):
// "*": [".snowpack/types/*"]
// More info: https://www.snowpack.dev/guides/streaming-imports
},
/* noEmit - Snowpack builds (emits) files, not tsc. */
"noEmit": true,
/* Additional Options */
"strict": false,
"skipLibCheck": true,
// "types": ["mocha", "snowpack-env"],
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"importsNotUsedAsValues": "error"
}
}