42
web-old/.eslintrc
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"vitest-globals/env": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:vitest-globals/recommended", "preact", "prettier"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"settings": {
|
||||
"jest": {
|
||||
"version": 27
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["*.d.ts"],
|
||||
"rules": {
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"objects": "always-multiline",
|
||||
"arrays": "always-multiline",
|
||||
"imports": "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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
25
web-old/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# 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?
|
||||
.npm
|
||||
5
web-old/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
}
|
||||
104
web-old/__test__/handlers.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { rest } from 'msw';
|
||||
// import { API_HOST } from '../src/env';
|
||||
|
||||
export const handlers = [
|
||||
rest.get(`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'] },
|
||||
audio: { enabled: false, enabled_in_config: false },
|
||||
record: { enabled: true, enabled_in_config: true },
|
||||
detect: { width: 1280, height: 720 },
|
||||
snapshots: {},
|
||||
restream: { enabled: true, jsmpeg: { height: 720 } },
|
||||
ui: { dashboard: true, order: 0 },
|
||||
},
|
||||
side: {
|
||||
name: 'side',
|
||||
objects: { track: ['taco', 'cat', 'dog'] },
|
||||
audio: { enabled: false, enabled_in_config: false },
|
||||
record: { enabled: false, enabled_in_config: true },
|
||||
detect: { width: 1280, height: 720 },
|
||||
snapshots: {},
|
||||
restream: { enabled: true, jsmpeg: { height: 720 } },
|
||||
ui: { dashboard: true, order: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get(`api/stats`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
cpu_usages: { 74: {cpu: 6, mem: 6}, 64: { cpu: 5, mem: 5 }, 54: { cpu: 4, mem: 4 }, 71: { cpu: 3, mem: 3}, 60: {cpu: 2, mem: 2}, 72: {cpu: 1, mem: 1} },
|
||||
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, ffmpeg_pid: 72 },
|
||||
side: {
|
||||
camera_fps: 6.9,
|
||||
capture_pid: 71,
|
||||
detection_fps: 0.0,
|
||||
pid: 60,
|
||||
process_fps: 0.0,
|
||||
skipped_fps: 0.0,
|
||||
ffmpeg_pid: 74,
|
||||
},
|
||||
service: { uptime: 34812, version: '0.8.1-d376f6b' },
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get(`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...',
|
||||
camera: 'camera_name',
|
||||
}))
|
||||
)
|
||||
);
|
||||
}),
|
||||
rest.get(`api/sub_labels`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json([
|
||||
'one',
|
||||
'two',
|
||||
])
|
||||
);
|
||||
}),
|
||||
rest.get(`api/labels`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json([
|
||||
'person',
|
||||
'car',
|
||||
])
|
||||
);
|
||||
}),
|
||||
rest.get(`api/go2rtc`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({"config_path":"/dev/shm/go2rtc.yaml","host":"frigate.yourdomain.local","rtsp":{"listen":"0.0.0.0:8554","default_query":"mp4","PacketSize":0},"version":"1.7.1"})
|
||||
);
|
||||
}),
|
||||
];
|
||||
36
web-old/__test__/test-setup.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import 'regenerator-runtime/runtime';
|
||||
// This creates a fake indexeddb so there is no need to mock idb-keyval
|
||||
import "fake-indexeddb/auto";
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// This configures a request mocking server with the given request handlers.
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
vi.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
web-old/__test__/testing-library.js
Normal 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 };
|
||||
BIN
web-old/images/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
web-old/images/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
web-old/images/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 800 B |
BIN
web-old/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
web-old/images/favicon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
46
web-old/images/favicon.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="888.000000pt" height="888.000000pt" viewBox="0 0 888.000000 888.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,888.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M8228 8865 c-2 -2 -25 -6 -53 -9 -38 -5 -278 -56 -425 -91 -33 -7
|
||||
-381 -98 -465 -121 -49 -14 -124 -34 -165 -45 -67 -18 -485 -138 -615 -176
|
||||
-50 -14 -106 -30 -135 -37 -8 -2 -35 -11 -60 -19 -25 -8 -85 -27 -135 -42 -49
|
||||
-14 -101 -31 -115 -36 -14 -5 -34 -11 -45 -13 -11 -3 -65 -19 -120 -36 -55
|
||||
-18 -127 -40 -160 -50 -175 -53 -247 -77 -550 -178 -364 -121 -578 -200 -820
|
||||
-299 -88 -36 -214 -88 -280 -115 -66 -27 -129 -53 -140 -58 -11 -5 -67 -29
|
||||
-125 -54 -342 -144 -535 -259 -579 -343 -34 -66 7 -145 156 -299 229 -238 293
|
||||
-316 340 -413 38 -80 41 -152 10 -281 -57 -234 -175 -543 -281 -732 -98 -174
|
||||
-172 -239 -341 -297 -116 -40 -147 -52 -210 -80 -107 -49 -179 -107 -290 -236
|
||||
-51 -59 -179 -105 -365 -131 -19 -2 -48 -7 -65 -9 -16 -3 -50 -8 -75 -11 -69
|
||||
-9 -130 -39 -130 -63 0 -24 31 -46 78 -56 18 -4 139 -8 270 -10 250 -4 302
|
||||
-11 335 -44 19 -18 19 -23 7 -46 -19 -36 -198 -121 -490 -233 -850 -328 -914
|
||||
-354 -1159 -473 -185 -90 -337 -186 -395 -249 -60 -65 -67 -107 -62 -350 3
|
||||
-113 7 -216 10 -230 3 -14 7 -52 10 -85 7 -70 14 -128 21 -170 2 -16 7 -48 10
|
||||
-70 3 -22 11 -64 16 -94 6 -30 12 -64 14 -75 1 -12 5 -34 9 -51 3 -16 8 -39
|
||||
10 -50 12 -57 58 -258 71 -310 9 -33 18 -69 20 -79 25 -110 138 -416 216 -582
|
||||
21 -47 39 -87 39 -90 0 -7 217 -438 261 -521 109 -201 293 -501 347 -564 11
|
||||
-13 37 -44 56 -68 69 -82 126 -109 160 -75 26 25 14 65 -48 164 -138 218 -142
|
||||
245 -138 800 2 206 4 488 5 625 1 138 -1 293 -6 345 -28 345 -28 594 -1 760
|
||||
12 69 54 187 86 235 33 52 188 212 293 302 98 84 108 93 144 121 19 15 52 42
|
||||
75 61 78 64 302 229 426 313 248 169 483 297 600 326 53 14 205 6 365 -17 33
|
||||
-5 155 -8 270 -6 179 3 226 7 316 28 58 13 140 25 182 26 82 2 120 6 217 22
|
||||
73 12 97 16 122 18 12 1 23 21 38 70 l20 68 74 -17 c81 -20 155 -30 331 -45
|
||||
69 -6 132 -8 715 -20 484 -11 620 -8 729 16 85 19 131 63 98 96 -25 26 -104
|
||||
34 -302 32 -373 -2 -408 -1 -471 26 -90 37 2 102 171 120 33 3 76 8 95 10 19
|
||||
2 71 7 115 10 243 17 267 20 338 37 145 36 47 102 -203 137 -136 19 -262 25
|
||||
-490 22 -124 -2 -362 -4 -530 -4 l-305 -1 -56 26 c-65 31 -171 109 -238 176
|
||||
-52 51 -141 173 -141 191 0 6 -6 22 -14 34 -18 27 -54 165 -64 244 -12 98 -6
|
||||
322 12 414 9 47 29 127 45 176 26 80 58 218 66 278 1 11 6 47 10 80 3 33 8 70
|
||||
10 83 2 13 7 53 11 90 3 37 8 74 9 83 22 118 22 279 -1 464 -20 172 -20 172
|
||||
70 238 108 79 426 248 666 355 25 11 77 34 115 52 92 42 443 191 570 242 55
|
||||
22 109 44 120 48 24 11 130 52 390 150 199 75 449 173 500 195 17 7 118 50
|
||||
225 95 237 100 333 143 490 220 229 113 348 191 337 223 -3 10 -70 20 -79 12z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
BIN
web-old/images/marker.png
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
web-old/images/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
26
web-old/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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="/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="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/images/favicon.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="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="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16624
web-old/package-lock.json
generated
Normal file
62
web-old/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "frigate",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
||||
"axios": "^1.5.0",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"immer": "^10.0.1",
|
||||
"monaco-yaml": "^4.0.4",
|
||||
"preact": "^10.17.1",
|
||||
"preact-async-route": "^2.2.1",
|
||||
"preact-router": "^4.1.0",
|
||||
"react": "npm:@preact/compat@^17.1.2",
|
||||
"react-dom": "npm:@preact/compat@^17.1.2",
|
||||
"react-use-websocket": "^3.0.0",
|
||||
"strftime": "^0.10.1",
|
||||
"swr": "^1.3.0",
|
||||
"video.js": "^8.5.2",
|
||||
"videojs-playlist": "^5.1.0",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.5.0",
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@testing-library/jest-dom": "^6.1.2",
|
||||
"@testing-library/preact": "^3.2.3",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"@vitest/coverage-v8": "^0.34.3",
|
||||
"@vitest/ui": "^0.34.3",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.2.3",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vitest-globals": "^1.4.0",
|
||||
"fake-indexeddb": "^4.0.1",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^22.0.0",
|
||||
"msw": "^1.2.1",
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.0.3",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.3"
|
||||
}
|
||||
}
|
||||
6
web-old/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
web-old/public/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
web-old/public/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
19
web-old/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Frigate",
|
||||
"short_name": "Frigate",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
86
web-old/src/AppBar.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import BaseAppBar from './components/AppBar';
|
||||
import LinkedLogo from './components/LinkedLogo';
|
||||
import Menu, { MenuItem, MenuSeparator } from './components/Menu';
|
||||
import AutoAwesomeIcon from './icons/AutoAwesome';
|
||||
import LightModeIcon from './icons/LightMode';
|
||||
import DarkModeIcon from './icons/DarkMode';
|
||||
import FrigateRestartIcon from './icons/FrigateRestart';
|
||||
import Prompt from './components/Prompt';
|
||||
import { useDarkMode } from './context';
|
||||
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||
import { useRestart } from './api/ws';
|
||||
|
||||
export default function AppBar() {
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [showDialogWait, setShowDialogWait] = useState(false);
|
||||
const { setDarkMode } = useDarkMode();
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
const handleSelectDarkMode = useCallback(
|
||||
(value) => {
|
||||
setDarkMode(value);
|
||||
setShowMoreMenu(false);
|
||||
},
|
||||
[setDarkMode, setShowMoreMenu]
|
||||
);
|
||||
|
||||
const moreRef = useRef(null);
|
||||
|
||||
const handleShowMenu = useCallback(() => {
|
||||
setShowMoreMenu(true);
|
||||
}, [setShowMoreMenu]);
|
||||
|
||||
const handleDismissMoreMenu = useCallback(() => {
|
||||
setShowMoreMenu(false);
|
||||
}, [setShowMoreMenu]);
|
||||
|
||||
const handleClickRestartDialog = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
setShowDialogWait(true);
|
||||
sendRestart();
|
||||
}, [setShowDialog, sendRestart]);
|
||||
|
||||
const handleDismissRestartDialog = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
}, [setShowDialog]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
setShowMoreMenu(false);
|
||||
setShowDialog(true);
|
||||
}, [setShowDialog]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<BaseAppBar title={LinkedLogo} overflowRef={moreRef} onOverflowClick={handleShowMenu} />
|
||||
{showMoreMenu ? (
|
||||
<Menu onDismiss={handleDismissMoreMenu} relativeTo={moreRef}>
|
||||
<MenuItem icon={AutoAwesomeIcon} label="Auto dark mode" value="media" onSelect={handleSelectDarkMode} />
|
||||
<MenuSeparator />
|
||||
<MenuItem icon={LightModeIcon} label="Light" value="light" onSelect={handleSelectDarkMode} />
|
||||
<MenuItem icon={DarkModeIcon} label="Dark" value="dark" onSelect={handleSelectDarkMode} />
|
||||
<MenuSeparator />
|
||||
<MenuItem icon={FrigateRestartIcon} label="Restart Frigate" onSelect={handleRestart} />
|
||||
</Menu>
|
||||
) : null}
|
||||
{showDialog ? (
|
||||
<Prompt
|
||||
onDismiss={handleDismissRestartDialog}
|
||||
title="Restart Frigate"
|
||||
text="Are you sure?"
|
||||
actions={[
|
||||
{ text: 'Yes', color: 'red', onClick: handleClickRestartDialog },
|
||||
{ text: 'Cancel', onClick: handleDismissRestartDialog },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{showDialogWait ? (
|
||||
<Prompt
|
||||
title="Restart in progress"
|
||||
text="This can take up to one minute, please wait before reloading the page."
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
110
web-old/src/Sidebar.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import LinkedLogo from './components/LinkedLogo';
|
||||
import { Match } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { ENV } from './env';
|
||||
import { useMemo } from 'preact/hooks'
|
||||
import useSWR from 'swr';
|
||||
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
const sortedCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(config.cameras)
|
||||
.filter(([_, conf]) => conf.ui.dashboard)
|
||||
.sort(([_, aConf], [__, bConf]) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const { birdseye } = config;
|
||||
|
||||
return (
|
||||
<NavigationDrawer header={<Header />}>
|
||||
<Destination href="/" text="Cameras" />
|
||||
<Match path="/cameras/:camera/:other?">
|
||||
{({ matches }) =>
|
||||
matches ? (
|
||||
<CameraSection sortedCameras={sortedCameras} />
|
||||
) : null
|
||||
}
|
||||
</Match>
|
||||
<Match path="/recording/:camera/:date?/:hour?/:seconds?">
|
||||
{({ matches }) =>
|
||||
matches ? (
|
||||
<RecordingSection sortedCameras={sortedCameras} />
|
||||
) : null
|
||||
}
|
||||
</Match>
|
||||
{birdseye?.enabled ? <Destination href="/birdseye" text="Birdseye" /> : null}
|
||||
<Destination href="/events" text="Events" />
|
||||
<Destination href="/exports" text="Exports" />
|
||||
<Separator />
|
||||
<Destination href="/storage" text="Storage" />
|
||||
<Destination href="/system" text="System" />
|
||||
<Destination href="/config" text="Config" />
|
||||
<Destination href="/logs" text="Logs" />
|
||||
<Separator />
|
||||
<div className="flex flex-grow" />
|
||||
{ENV !== 'production' ? (
|
||||
<Fragment>
|
||||
<Destination href="/styleguide" text="Style Guide" />
|
||||
<Separator />
|
||||
</Fragment>
|
||||
) : null}
|
||||
<Destination className="self-end" href="https://docs.frigate.video" text="Documentation" />
|
||||
<Destination className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
|
||||
</NavigationDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraSection({ sortedCameras }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
<div className="overflow-auto pr-2">
|
||||
{sortedCameras.map(([camera]) => (
|
||||
<Destination key={camera} href={`/cameras/${camera}`} text={camera.replaceAll('_', ' ')} />
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordingSection({ sortedCameras }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
<div className="overflow-auto pr-2">
|
||||
{sortedCameras.map(([camera, _]) => {
|
||||
return (
|
||||
<Destination
|
||||
key={camera}
|
||||
path={`/recording/${camera}/:date?/:hour?/:seconds?`}
|
||||
href={`/recording/${camera}`}
|
||||
text={camera.replaceAll('_', ' ')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const Header = memo(() => {
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
<LinkedLogo />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
web-old/src/__mocks__/env.js
Normal file
@@ -0,0 +1 @@
|
||||
export const ENV = 'test';
|
||||
1
web-old/src/__mocks__/styleMock.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
12
web-old/src/__tests__/App.test.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import App from '../app';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('App', () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('loads the camera dashboard', async () => {
|
||||
render(<App />);
|
||||
await screen.findByText('Cameras');
|
||||
expect(screen.queryByText('front')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
web-old/src/__tests__/AppBar.test.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { h } from 'preact';
|
||||
import * as Context from '../context';
|
||||
import AppBar from '../AppBar';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('AppBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Context, 'useDarkMode').mockImplementation(() => ({
|
||||
setDarkMode: vi.fn(),
|
||||
}));
|
||||
vi.spyOn(Context, 'DarkModeProvider').mockImplementation(({ children }) => {
|
||||
return <div>{children}</div>;
|
||||
});
|
||||
});
|
||||
|
||||
test('shows a menu on overflow click', async () => {
|
||||
render(
|
||||
<Context.DarkModeProvider>
|
||||
<Context.DrawerProvider>
|
||||
<AppBar />
|
||||
</Context.DrawerProvider>
|
||||
</Context.DarkModeProvider>
|
||||
);
|
||||
|
||||
const overflowButton = await screen.findByLabelText('More options');
|
||||
fireEvent.click(overflowButton);
|
||||
|
||||
const menu = await screen.findByRole('listbox');
|
||||
expect(menu).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sets dark mode on MenuItem select', async () => {
|
||||
const setDarkModeSpy = vi.fn();
|
||||
vi.spyOn(Context, 'useDarkMode').mockImplementation(() => ({
|
||||
setDarkMode: setDarkModeSpy,
|
||||
}));
|
||||
render(
|
||||
<Context.DarkModeProvider>
|
||||
<Context.DrawerProvider>
|
||||
<AppBar />
|
||||
</Context.DrawerProvider>
|
||||
</Context.DarkModeProvider>
|
||||
);
|
||||
|
||||
const overflowButton = await screen.findByLabelText('More options');
|
||||
fireEvent.click(overflowButton);
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
fireEvent.click(screen.getByText('Light'));
|
||||
expect(setDarkModeSpy).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
14
web-old/src/__tests__/Sidebar.test.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { h } from 'preact';
|
||||
import { DrawerProvider } from '../context';
|
||||
import Sidebar from '../Sidebar';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('does not render cameras by default', async () => {
|
||||
const { findByText } = render(<DrawerProvider><Sidebar /></DrawerProvider>);
|
||||
await findByText('Cameras');
|
||||
expect(screen.queryByRole('link', { name: 'front' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
23
web-old/src/api/__tests__/index.test.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { h } from 'preact';
|
||||
import * as WS from '../ws';
|
||||
import { ApiProvider, useApiHost } from '..';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('useApiHost', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(WS, 'WsProvider').mockImplementation(({ children }) => children);
|
||||
});
|
||||
|
||||
test('is set from the baseUrl', async () => {
|
||||
function Test() {
|
||||
const apiHost = useApiHost();
|
||||
return <div>{apiHost}</div>;
|
||||
}
|
||||
render(
|
||||
<ApiProvider>
|
||||
<Test />
|
||||
</ApiProvider>
|
||||
);
|
||||
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
154
web-old/src/api/__tests__/ws.test.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/* eslint-disable jest/no-disabled-tests */
|
||||
import { h } from 'preact';
|
||||
import { WS as frigateWS, WsProvider, useWs } from '../ws';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { WS } from 'jest-websocket-mock';
|
||||
|
||||
function Test() {
|
||||
const { state } = useContext(frigateWS);
|
||||
return state.__connected ? (
|
||||
<div data-testid="data">
|
||||
{Object.keys(state).map((key) => (
|
||||
<div key={key} data-testid={key}>
|
||||
{JSON.stringify(state[key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const TEST_URL = 'ws://test-foo:1234/ws';
|
||||
|
||||
describe('WsProvider', () => {
|
||||
let wsClient, wsServer;
|
||||
beforeEach(async () => {
|
||||
wsClient = {
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
wsServer = new WS(TEST_URL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test.skip('connects to the ws server', async () => {
|
||||
render(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(wsClient.args).toEqual([TEST_URL]);
|
||||
expect(screen.getByTestId('__connected')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
test.skip('receives data through useWs', async () => {
|
||||
function Test() {
|
||||
const {
|
||||
value: { payload, retain },
|
||||
connected,
|
||||
} = useWs('tacos');
|
||||
return connected ? (
|
||||
<div>
|
||||
<div data-testid="payload">{JSON.stringify(payload)}</div>
|
||||
<div data-testid="retain">{JSON.stringify(retain)}</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('payload');
|
||||
wsClient.onmessage({
|
||||
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
|
||||
});
|
||||
rerender(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
expect(screen.getByTestId('payload')).toHaveTextContent('{"yes":true}');
|
||||
expect(screen.getByTestId('retain')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
test.skip('can send values through useWs', async () => {
|
||||
function Test() {
|
||||
const { send, connected } = useWs('tacos');
|
||||
const handleClick = useCallback(() => {
|
||||
send({ yes: true });
|
||||
}, [send]);
|
||||
return connected ? <button onClick={handleClick}>click me</button> : null;
|
||||
}
|
||||
|
||||
render(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByRole('button');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await expect(wsClient.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false })
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('prefills the recordings/detect/snapshots state from config', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
||||
const config = {
|
||||
cameras: {
|
||||
front: {
|
||||
name: 'front',
|
||||
detect: { enabled: true },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: true },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
side: {
|
||||
name: 'side',
|
||||
detect: { enabled: false },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: false },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<WsProvider config={config} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
cameras: {},
|
||||
};
|
||||
1
web-old/src/api/baseUrl.js
Normal file
@@ -0,0 +1 @@
|
||||
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||
33
web-old/src/api/index.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { h } from 'preact';
|
||||
import { baseUrl } from './baseUrl';
|
||||
import useSWR, { SWRConfig } from 'swr';
|
||||
import { WsProvider } from './ws';
|
||||
import axios from 'axios';
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||
axios.defaults.headers.common = {
|
||||
'X-CSRF-TOKEN': 1,
|
||||
'X-CACHE-BYPASS': 1,
|
||||
};
|
||||
|
||||
export function ApiProvider({ children, options }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (path, params) => axios.get(path, { params }).then((res) => res.data),
|
||||
...options,
|
||||
}}
|
||||
>
|
||||
<WsWithConfig>{children}</WsWithConfig>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
function WsWithConfig({ children }) {
|
||||
const { data } = useSWR('config');
|
||||
return data ? <WsProvider config={data}>{children}</WsProvider> : children;
|
||||
}
|
||||
|
||||
export function useApiHost() {
|
||||
return baseUrl;
|
||||
}
|
||||
134
web-old/src/api/ws.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { h, createContext } from 'preact';
|
||||
import { baseUrl } from './baseUrl';
|
||||
import { produce } from 'immer';
|
||||
import { useCallback, useContext, useEffect, useReducer } from 'preact/hooks';
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||
|
||||
const initialState = Object.freeze({ __connected: false });
|
||||
export const WS = createContext({ state: initialState, readyState: null, sendJsonMessage: () => {} });
|
||||
|
||||
function reducer(state, { topic, payload, retain }) {
|
||||
switch (topic) {
|
||||
case '__CLIENT_CONNECTED':
|
||||
return produce(state, (draftState) => {
|
||||
draftState.__connected = true;
|
||||
});
|
||||
|
||||
default:
|
||||
return produce(state, (draftState) => {
|
||||
let parsedPayload = payload;
|
||||
try {
|
||||
parsedPayload = payload && JSON.parse(payload);
|
||||
} catch (e) {}
|
||||
draftState[topic] = {
|
||||
lastUpdate: Date.now(),
|
||||
payload: parsedPayload,
|
||||
retain,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function WsProvider({
|
||||
config,
|
||||
children,
|
||||
wsUrl = `${baseUrl.replace(/^http/, 'ws')}ws`,
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
||||
|
||||
onMessage: (event) => {
|
||||
dispatch(JSON.parse(event.data));
|
||||
},
|
||||
onOpen: () => dispatch({ topic: '__CLIENT_CONNECTED' }),
|
||||
shouldReconnect: () => true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(config.cameras).forEach((camera) => {
|
||||
const { name, record, detect, snapshots, audio } = config.cameras[camera];
|
||||
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF', retain: false });
|
||||
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF', retain: false });
|
||||
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF', retain: false });
|
||||
dispatch({ topic: `${name}/audio/state`, payload: audio.enabled ? 'ON' : 'OFF', retain: false });
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
return <WS.Provider value={{ state, readyState, sendJsonMessage }}>{children}</WS.Provider>;
|
||||
}
|
||||
|
||||
export function useWs(watchTopic, publishTopic) {
|
||||
const { state, readyState, sendJsonMessage } = useContext(WS);
|
||||
|
||||
const value = state[watchTopic] || { payload: null };
|
||||
|
||||
const send = useCallback(
|
||||
(payload, retain = false) => {
|
||||
if (readyState === ReadyState.OPEN) {
|
||||
sendJsonMessage({
|
||||
topic: publishTopic || watchTopic,
|
||||
payload,
|
||||
retain,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendJsonMessage, readyState, watchTopic, publishTopic]
|
||||
);
|
||||
|
||||
return { value, send, connected: state.__connected };
|
||||
}
|
||||
|
||||
export function useDetectState(camera) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
connected,
|
||||
} = useWs(`${camera}/detect/state`, `${camera}/detect/set`);
|
||||
return { payload, send, connected };
|
||||
}
|
||||
|
||||
export function useRecordingsState(camera) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
connected,
|
||||
} = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`);
|
||||
return { payload, send, connected };
|
||||
}
|
||||
|
||||
export function useSnapshotsState(camera) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
connected,
|
||||
} = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
|
||||
return { payload, send, connected };
|
||||
}
|
||||
|
||||
export function useAudioState(camera) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
connected,
|
||||
} = useWs(`${camera}/audio/state`, `${camera}/audio/set`);
|
||||
return { payload, send, connected };
|
||||
}
|
||||
|
||||
export function usePtzCommand(camera) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
connected,
|
||||
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
|
||||
return { payload, send, connected };
|
||||
}
|
||||
|
||||
export function useRestart() {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
connected,
|
||||
} = useWs('restart', 'restart');
|
||||
return { payload, send, connected };
|
||||
}
|
||||
25
web-old/src/app.css
Normal file
@@ -0,0 +1,25 @@
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.preact:hover {
|
||||
filter: drop-shadow(0 0 2em #673ab8aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
53
web-old/src/app.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as Routes from './routes';
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from './components/ActivityIndicator';
|
||||
import AsyncRoute from 'preact-async-route';
|
||||
import AppBar from './AppBar';
|
||||
import Cameras from './routes/Cameras';
|
||||
import { Router } from 'preact-router';
|
||||
import Sidebar from './Sidebar';
|
||||
import { DarkModeProvider, DrawerProvider } from './context';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function App() {
|
||||
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 />
|
||||
{!config ? (
|
||||
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<Sidebar />
|
||||
<div className="w-full flex-auto mt-16 min-w-0">
|
||||
<Router>
|
||||
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
||||
<AsyncRoute path="/cameras/:camera" getComponent={cameraComponent} />
|
||||
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||
<AsyncRoute path="/exports" getComponent={Routes.getExports} />
|
||||
<AsyncRoute
|
||||
path="/recording/:camera/:date?/:hour?/:minute?/:second?"
|
||||
getComponent={Routes.getRecording}
|
||||
/>
|
||||
<AsyncRoute path="/storage" getComponent={Routes.getStorage} />
|
||||
<AsyncRoute path="/system" getComponent={Routes.getSystem} />
|
||||
<AsyncRoute path="/config" getComponent={Routes.getConfig} />
|
||||
<AsyncRoute path="/logs" getComponent={Routes.getLogs} />
|
||||
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
||||
<Cameras default path="/" />
|
||||
</Router>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerProvider>
|
||||
</DarkModeProvider>
|
||||
);
|
||||
}
|
||||
1
web-old/src/assets/preact.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296"><path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path><path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path><path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path><path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
15
web-old/src/components/ActivityIndicator.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4 border-2 border-t-2',
|
||||
md: 'h-8 w-8 border-4 border-t-4',
|
||||
lg: 'h-16 w-16 border-8 border-t-8',
|
||||
};
|
||||
|
||||
export default function ActivityIndicator({ size = 'md' }) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center" aria-label="Loading…">
|
||||
<div className={`activityindicator ease-in rounded-full border-gray-200 text-blue-500 ${sizes[size]}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
web-old/src/components/AppBar.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './Button';
|
||||
import MenuIcon from '../icons/Menu';
|
||||
import MoreIcon from '../icons/More';
|
||||
import { useDrawer } from '../context';
|
||||
import { useLayoutEffect, useCallback, useState } from 'preact/hooks';
|
||||
|
||||
// We would typically preserve these in component state
|
||||
// But need to avoid too many re-renders
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
|
||||
const [show, setShow] = useState(true);
|
||||
const [atZero, setAtZero] = useState(window.scrollY === 0);
|
||||
const { setShowDrawer } = useDrawer();
|
||||
|
||||
const scrollListener = useCallback(() => {
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(scrollY <= 0 || lastScrollY > scrollY);
|
||||
setAtZero(scrollY === 0);
|
||||
lastScrollY = scrollY;
|
||||
});
|
||||
}, [setShow]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.addEventListener('scroll', scrollListener);
|
||||
return () => {
|
||||
document.removeEventListener('scroll', scrollListener);
|
||||
};
|
||||
}, [scrollListener]);
|
||||
|
||||
const handleShowDrawer = useCallback(() => {
|
||||
setShowDrawer(true);
|
||||
}, [setShowDrawer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="appbar"
|
||||
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
|
||||
!show ? '-translate-y-full' : 'translate-y-0'
|
||||
} ${!atZero ? 'shadow-sm' : ''}`}
|
||||
data-testid="appbar"
|
||||
>
|
||||
<div className="lg:hidden">
|
||||
<Button color="black" className="rounded-full w-10 h-10" onClick={handleShowDrawer} type="text">
|
||||
<MenuIcon className="w-10 h-10" />
|
||||
</Button>
|
||||
</div>
|
||||
<Title />
|
||||
<div className="flex-grow-1 flex justify-end w-full">
|
||||
{overflowRef && onOverflowClick ? (
|
||||
<div className="w-auto" ref={overflowRef}>
|
||||
<Button
|
||||
aria-label="More options"
|
||||
color="black"
|
||||
className="rounded-full w-9 h-9"
|
||||
onClick={onOverflowClick}
|
||||
type="text"
|
||||
>
|
||||
<MoreIcon className="w-10 h-10" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
web-old/src/components/AutoUpdatingCameraImage.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { h } from 'preact';
|
||||
import CameraImage from './CameraImage';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||
|
||||
export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true, className }) {
|
||||
const [key, setKey] = useState(Date.now());
|
||||
const [fps, setFps] = useState(0);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const loadTime = Date.now() - key;
|
||||
setFps((1000 / Math.max(loadTime, MIN_LOAD_TIMEOUT_MS)).toFixed(1));
|
||||
setTimeout(
|
||||
() => {
|
||||
setKey(Date.now());
|
||||
},
|
||||
loadTime > MIN_LOAD_TIMEOUT_MS ? 1 : MIN_LOAD_TIMEOUT_MS
|
||||
);
|
||||
}, [key, setFps]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
web-old/src/components/BubbleButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { h, JSX } from 'preact';
|
||||
|
||||
interface BubbleButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
children?: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const BubbleButton = ({
|
||||
variant = 'primary',
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: BubbleButtonProps) => {
|
||||
const BASE_CLASS = 'rounded-full px-4 py-2';
|
||||
const PRIMARY_CLASS = 'text-white bg-blue-500 dark:text-black dark:bg-white';
|
||||
const SECONDARY_CLASS = 'text-black dark:text-white bg-transparent';
|
||||
let computedClass = BASE_CLASS;
|
||||
|
||||
if (disabled) {
|
||||
computedClass += ' text-gray-200 dark:text-gray-200';
|
||||
} else if (variant === 'primary') {
|
||||
computedClass += ` ${PRIMARY_CLASS}`;
|
||||
} else if (variant === 'secondary') {
|
||||
computedClass += ` ${SECONDARY_CLASS}`;
|
||||
}
|
||||
|
||||
const onClickHandler = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button onClick={onClickHandler} className={`${computedClass} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
116
web-old/src/components/Button.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const ButtonColors = {
|
||||
blue: {
|
||||
contained: 'bg-blue-500 focus:bg-blue-400 active:bg-blue-600 ring-blue-300',
|
||||
outlined:
|
||||
'text-blue-500 border-2 border-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
|
||||
text: 'text-blue-500 hover:bg-blue-500 hover:bg-opacity-20 focus:bg-blue-500 focus:bg-opacity-40 active:bg-blue-500 active:bg-opacity-40',
|
||||
iconOnly: 'text-blue-500 hover:text-blue-200',
|
||||
},
|
||||
red: {
|
||||
contained: 'bg-red-500 focus:bg-red-400 active:bg-red-600 ring-red-300',
|
||||
outlined:
|
||||
'text-red-500 border-2 border-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
|
||||
text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40',
|
||||
iconOnly: 'text-red-500 hover:text-red-200',
|
||||
},
|
||||
yellow: {
|
||||
contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300',
|
||||
outlined:
|
||||
'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
|
||||
text: 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40',
|
||||
iconOnly: 'text-yellow-500 hover:text-yellow-200',
|
||||
},
|
||||
green: {
|
||||
contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300',
|
||||
outlined:
|
||||
'text-green-500 border-2 border-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
||||
text: 'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
||||
iconOnly: 'text-green-500 hover:text-green-200',
|
||||
},
|
||||
gray: {
|
||||
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
|
||||
outlined:
|
||||
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||
iconOnly: 'text-gray-500 hover:text-gray-200',
|
||||
},
|
||||
disabled: {
|
||||
contained: 'bg-gray-400',
|
||||
outlined:
|
||||
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||
text: 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||
iconOnly: 'text-gray-500 hover:text-gray-200',
|
||||
},
|
||||
black: {
|
||||
contained: '',
|
||||
outlined: '',
|
||||
text: 'text-black dark:text-white',
|
||||
iconOnly: '',
|
||||
},
|
||||
};
|
||||
|
||||
const ButtonTypes = {
|
||||
contained: 'text-white shadow focus:shadow-xl hover:shadow-md',
|
||||
outlined: '',
|
||||
text: 'transition-opacity',
|
||||
iconOnly: 'transition-opacity',
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
className = '',
|
||||
color = 'blue',
|
||||
disabled = false,
|
||||
ariaCapitalize = false,
|
||||
href,
|
||||
target,
|
||||
type = 'contained',
|
||||
...attrs
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const ref = useRef();
|
||||
|
||||
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
||||
ButtonColors[disabled ? 'disabled' : color][type]
|
||||
} font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||
disabled ? 'cursor-not-allowed' : `${type == 'iconOnly' ? '' : 'focus:ring-2'} cursor-pointer`
|
||||
}`;
|
||||
|
||||
if (disabled) {
|
||||
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
||||
}
|
||||
|
||||
const handleMousenter = useCallback(() => {
|
||||
setHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseleave = useCallback(() => {
|
||||
setHovered(false);
|
||||
}, []);
|
||||
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Element
|
||||
role="button"
|
||||
aria-disabled={disabled ? 'true' : 'false'}
|
||||
tabindex="0"
|
||||
className={classes}
|
||||
href={href}
|
||||
target={target}
|
||||
ref={ref}
|
||||
onmouseenter={handleMousenter}
|
||||
onmouseleave={handleMouseleave}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} capitalize={ariaCapitalize} /> : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
46
web-old/src/components/ButtonsTabbed.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function ButtonsTabbed({
|
||||
viewModes = [''],
|
||||
currentViewMode = '',
|
||||
setViewMode = null,
|
||||
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`,
|
||||
}) {
|
||||
const [selected, setSelected] = useState(viewModes ? viewModes.indexOf(currentViewMode) : 0);
|
||||
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 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 key={i} onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
|
||||
{captitalize(item)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
344
web-old/src/components/Calendar.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
|
||||
import ArrowRight from '../icons/ArrowRight';
|
||||
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
|
||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||
|
||||
const Calendar = ({ onChange, calendarRef, close, dateRange, children }) => {
|
||||
const keyRef = useRef([]);
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
|
||||
const monthMap = useMemo(
|
||||
() => [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const [state, setState] = useState({
|
||||
getMonthDetails: [],
|
||||
year,
|
||||
month,
|
||||
selectedDay: null,
|
||||
timeRange: dateRange,
|
||||
monthDetails: null,
|
||||
});
|
||||
|
||||
const getNumberOfDays = useCallback((year, month) => {
|
||||
return 40 - new Date(year, month, 40).getDate();
|
||||
}, []);
|
||||
|
||||
const getDayDetails = useCallback(
|
||||
(args) => {
|
||||
const date = args.index - args.firstDay;
|
||||
const day = args.index % 7;
|
||||
let prevMonth = args.month - 1;
|
||||
let prevYear = args.year;
|
||||
if (prevMonth < 0) {
|
||||
prevMonth = 11;
|
||||
prevYear--;
|
||||
}
|
||||
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
|
||||
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
|
||||
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
|
||||
const timestamp = new Date(args.year, args.month, _date).getTime();
|
||||
return {
|
||||
date: _date,
|
||||
day,
|
||||
month,
|
||||
timestamp,
|
||||
dayString: daysMap[day],
|
||||
};
|
||||
},
|
||||
[getNumberOfDays, daysMap]
|
||||
);
|
||||
|
||||
const getMonthDetails = useCallback(
|
||||
(year, month) => {
|
||||
const firstDay = new Date(year, month).getDay();
|
||||
const numberOfDays = getNumberOfDays(year, month);
|
||||
const monthArray = [];
|
||||
const rows = 6;
|
||||
let currentDay = null;
|
||||
let index = 0;
|
||||
const cols = 7;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
currentDay = getDayDetails({
|
||||
index,
|
||||
numberOfDays,
|
||||
firstDay,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
monthArray.push(currentDay);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return monthArray;
|
||||
},
|
||||
[getNumberOfDays, getDayDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedDay: todayTimestamp,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
}));
|
||||
}, [year, month, getMonthDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
// add refs for keyboard navigation
|
||||
if (state.monthDetails) {
|
||||
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
|
||||
}
|
||||
// set today date in focus for keyboard navigation
|
||||
const todayDate = new Date(todayTimestamp).getDate();
|
||||
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
|
||||
}, [state.monthDetails]);
|
||||
|
||||
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
|
||||
|
||||
const isSelectedRange = useCallback(
|
||||
(day) => {
|
||||
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||
|
||||
return day.timestamp < state.timeRange.before && day.timestamp >= new Date(state.timeRange.after).setHours(0);
|
||||
},
|
||||
[state.timeRange]
|
||||
);
|
||||
|
||||
const isFirstDayInRange = useCallback(
|
||||
(day) => {
|
||||
if (isCurrentDay(day)) return;
|
||||
return new Date(state.timeRange.after).setHours(0) === day.timestamp;
|
||||
},
|
||||
[state.timeRange.after]
|
||||
);
|
||||
|
||||
const isLastDayInRange = useCallback(
|
||||
(day) => {
|
||||
// if the hour is not above 0, we will use 24 hour.
|
||||
const beforeHour = new Date(state.timeRange.before).getHours() || 24;
|
||||
|
||||
/**
|
||||
* When user selects a day in the calendar, the before will be 00:00.
|
||||
* When user selects a time in timepicker, the day.timestamp hour must be changed to match the selected end () hour.
|
||||
*/
|
||||
return state.timeRange.before === new Date(day.timestamp).setHours(beforeHour);
|
||||
},
|
||||
[state.timeRange.before]
|
||||
);
|
||||
|
||||
const getMonthStr = useCallback(
|
||||
(month) => {
|
||||
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||
},
|
||||
[monthMap]
|
||||
);
|
||||
|
||||
const onDateClick = (day) => {
|
||||
const { before, after } = state.timeRange;
|
||||
let timeRange = { before: null, after: null };
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// user has selected a date > after
|
||||
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
|
||||
timeRange = {
|
||||
after,
|
||||
before:
|
||||
day.timestamp >= todayTimestamp
|
||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// reset values
|
||||
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
|
||||
timeRange = { before: null, after: null };
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
timeRange,
|
||||
selectedDay: day.timestamp,
|
||||
}));
|
||||
|
||||
if (onChange) {
|
||||
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||
}
|
||||
};
|
||||
|
||||
const setYear = useCallback(
|
||||
(offset) => {
|
||||
const year = state.year + offset;
|
||||
const month = state.month;
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
},
|
||||
[state.year, state.month, getMonthDetails]
|
||||
);
|
||||
|
||||
const setMonth = (offset) => {
|
||||
let year = state.year;
|
||||
let month = state.month + offset;
|
||||
if (month === -1) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else if (month === 12) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
month,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeydown = (e, day, index) => {
|
||||
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
|
||||
e.preventDefault();
|
||||
day.month === 0 && onDateClick(day);
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
index > 0 && keyRef.current[index - 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
index < 41 && keyRef.current[index + 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
index > 6 && keyRef.current[index - 7].focus();
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
index < 36 && keyRef.current[index + 7].focus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCalendar = () => {
|
||||
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 hover:border hover:rounded-md border-gray-600 ${
|
||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||
}
|
||||
${isFirstDayInRange(day) ? ' rounded-l-xl hover:rounded-l-xl' : ''}
|
||||
${isSelectedRange(day) ? ' bg-blue-600 hover:rounded-none' : ''}
|
||||
${isLastDayInRange(day) ? ' rounded-r-xl hover: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>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full flex justify-start flex-shrink">
|
||||
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
|
||||
<div key={i} className="w-12 text-xs font-light text-center">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-56">{days}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="select-none w-11/12 flex" ref={calendarRef}>
|
||||
<div className="px-6">
|
||||
<div className="flex items-center">
|
||||
<div className="w-1/6 relative flex justify-around">
|
||||
<div
|
||||
tabIndex={100}
|
||||
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"
|
||||
onClick={() => setYear(-1)}
|
||||
>
|
||||
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={101}
|
||||
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"
|
||||
onClick={() => setMonth(-1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6 transform rotate-180 red" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
|
||||
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={102}
|
||||
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"
|
||||
onClick={() => setMonth(1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">{renderCalendar()}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
257
web-old/src/components/CameraControlPanel.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useCallback, useState } from 'preact/hooks';
|
||||
import useSWR from 'swr';
|
||||
import { usePtzCommand } from '../api/ws';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
import ArrowUpDouble from '../icons/ArrowUpDouble';
|
||||
import ArrowDownDouble from '../icons/ArrowDownDouble';
|
||||
import ArrowLeftDouble from '../icons/ArrowLeftDouble';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
|
||||
export default function CameraControlPanel({ camera = '' }) {
|
||||
const { data: ptz } = useSWR(`${camera}/ptz/info`);
|
||||
const [currentPreset, setCurrentPreset] = useState('');
|
||||
|
||||
const { payload: _, send: sendPtz } = usePtzCommand(camera);
|
||||
|
||||
const onSetPreview = async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (currentPreset == 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
sendPtz(`preset_${currentPreset}`);
|
||||
setCurrentPreset('');
|
||||
};
|
||||
|
||||
const onSetMove = useCallback(async (e, dir) => {
|
||||
e.stopPropagation();
|
||||
sendPtz(`MOVE_${dir}`);
|
||||
setCurrentPreset('');
|
||||
}, [sendPtz, setCurrentPreset]);
|
||||
|
||||
const onSetZoom = useCallback(async (e, dir) => {
|
||||
e.stopPropagation();
|
||||
sendPtz(`ZOOM_${dir}`);
|
||||
setCurrentPreset('');
|
||||
}, [sendPtz, setCurrentPreset]);
|
||||
|
||||
const onSetStop = useCallback(async (e) => {
|
||||
e.stopPropagation();
|
||||
sendPtz('STOP');
|
||||
}, [sendPtz]);
|
||||
|
||||
const keydownListener = useCallback((e) => {
|
||||
if (!ptz || !e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ptz.features.includes('pt')) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'LEFT');
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'RIGHT');
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'UP');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onSetMove(e, 'DOWN');
|
||||
}
|
||||
|
||||
if (ptz.features.includes('zoom')) {
|
||||
if (e.key == '+') {
|
||||
e.preventDefault();
|
||||
onSetZoom(e, 'IN');
|
||||
} else if (e.key == '-') {
|
||||
e.preventDefault();
|
||||
onSetZoom(e, 'OUT');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onSetMove, onSetZoom, ptz]);
|
||||
|
||||
const keyupListener = useCallback((e) => {
|
||||
if (!e || e.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === '+' ||
|
||||
e.key === '-'
|
||||
) {
|
||||
e.preventDefault();
|
||||
onSetStop(e);
|
||||
}
|
||||
}, [onSetStop]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', keydownListener);
|
||||
document.addEventListener('keyup', keyupListener);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keydownListener);
|
||||
document.removeEventListener('keyup', keyupListener);
|
||||
};
|
||||
}, [keydownListener, keyupListener, ptz]);
|
||||
|
||||
if (!ptz) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="control-panel" className="p-4 text-center sm:flex justify-start">
|
||||
{ptz.features.includes('pt') && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-44 px-4">
|
||||
<Heading size="xs" className="my-4">
|
||||
Pan / Tilt
|
||||
</Heading>
|
||||
<div className="w-full flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'UP')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'UP');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowUpDouble className="h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full flex justify-between">
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'LEFT')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'LEFT');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowLeftDouble className="btn h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'RIGHT')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'RIGHT');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowRightDouble className="h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetMove(e, 'DOWN')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetMove(e, 'DOWN');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ArrowDownDouble className="h-12 p-2 bg-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ptz.features.includes('zoom') && (
|
||||
<div className="px-4 sm:w-44">
|
||||
<Heading size="xs" className="my-4">
|
||||
Zoom
|
||||
</Heading>
|
||||
<div className="w-full flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetZoom(e, 'IN')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetZoom(e, 'IN');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">+</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-12" />
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onMouseDown={(e) => onSetZoom(e, 'OUT')}
|
||||
onMouseUp={(e) => onSetStop(e)}
|
||||
onTouchStart={(e) => {
|
||||
onSetZoom(e, 'OUT');
|
||||
e.preventDefault();
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
onSetStop(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="h-12 w-12 p-2 text-2xl bg-slate-500 select-none">-</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(ptz.presets || []).length > 0 && (
|
||||
<div className="px-4">
|
||||
<Heading size="xs" className="my-4">
|
||||
Presets
|
||||
</Heading>
|
||||
<div className="py-4">
|
||||
<select
|
||||
className="cursor-pointer rounded dark:bg-slate-800"
|
||||
value={currentPreset}
|
||||
onChange={(e) => {
|
||||
setCurrentPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Select Preset</option>
|
||||
{ptz.presets.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button onClick={(e) => onSetPreview(e)}>Move Camera To Preset</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
web-old/src/components/CameraImage.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
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 } = useSWR('config');
|
||||
const apiHost = useApiHost();
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const [{ width: containerWidth }] = useResizeObserver(containerRef);
|
||||
|
||||
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
|
||||
// https://github.com/blakeblackshear/frigate/issues/1657
|
||||
let scrollBarWidth = 0;
|
||||
if (window.innerWidth && document.body.offsetWidth) {
|
||||
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
|
||||
}
|
||||
const availableWidth = scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth;
|
||||
|
||||
const { name } = config ? config.cameras[camera] : '';
|
||||
const enabled = config ? config.cameras[camera].enabled : 'True';
|
||||
const { width, height } = config ? config.cameras[camera].detect : { width: 1, height: 1 };
|
||||
const aspectRatio = width / height;
|
||||
|
||||
const scaledHeight = useMemo(() => {
|
||||
const scaledHeight = Math.floor(availableWidth / aspectRatio);
|
||||
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
|
||||
|
||||
if (finalHeight > 0) {
|
||||
return finalHeight;
|
||||
}
|
||||
|
||||
return 100;
|
||||
}, [availableWidth, aspectRatio, height, stretch]);
|
||||
const scaledWidth = useMemo(
|
||||
() => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
|
||||
[scaledHeight, aspectRatio, scrollBarWidth]
|
||||
);
|
||||
|
||||
const img = useMemo(() => new Image(), []);
|
||||
img.onload = useCallback(
|
||||
(event) => {
|
||||
setHasLoaded(true);
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
||||
}
|
||||
onload && onload(event);
|
||||
},
|
||||
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
|
||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
{enabled ? (
|
||||
<canvas data-testid="cameraimage-canvas" height={scaledHeight} ref={canvasRef} width={scaledWidth} />
|
||||
) : (
|
||||
<div class="text-center pt-6">Camera is disabled in config, no stream or snapshot available!</div>
|
||||
)}
|
||||
{!hasLoaded && enabled ? (
|
||||
<div className="absolute inset-0 flex justify-center" style={`height: ${scaledHeight}px`}>
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
web-old/src/components/Card.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
|
||||
export default function Box({
|
||||
buttons = [],
|
||||
className = '',
|
||||
content,
|
||||
elevated = true,
|
||||
header,
|
||||
href,
|
||||
icons = [],
|
||||
media = null,
|
||||
...props
|
||||
}) {
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
const typeClasses = elevated
|
||||
? 'shadow-md hover:shadow-lg transition-shadow'
|
||||
: 'border border-gray-200 dark:border-gray-700';
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg overflow-hidden ${typeClasses} ${className}`}>
|
||||
{media || header ? (
|
||||
<Element href={href} {...props}>
|
||||
{media}
|
||||
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
||||
</Element>
|
||||
) : null}
|
||||
{buttons.length || content || icons.length ? (
|
||||
<div className="px-4 pb-2">
|
||||
{content || null}
|
||||
{buttons.length ? (
|
||||
<div className="flex space-x-4 -ml-2">
|
||||
{buttons.map(({ name, href }) => (
|
||||
<Button key={name} href={href} type="text">
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
<div class="flex-grow" />
|
||||
{icons.map(({ name, icon: Icon, ...props }) => (
|
||||
<Button aria-label={name} ariaCapitalize={true} className="rounded-full" key={name} type="text" {...props}>
|
||||
<Icon className="w-6" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
web-old/src/components/DatePicker.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export const DateFilterOptions = [
|
||||
{
|
||||
label: 'All',
|
||||
value: ['all'],
|
||||
},
|
||||
{
|
||||
label: 'Today',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date().setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Yesterday',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 7 Days',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Custom Range',
|
||||
value: 'custom_range',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DatePicker({
|
||||
helpText,
|
||||
keyboardType = 'text',
|
||||
inputRef,
|
||||
label,
|
||||
leadingIcon: LeadingIcon,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
}) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState(propValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (propValue !== value) {
|
||||
setValue(propValue);
|
||||
}
|
||||
}, [propValue, setValue, value]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus(event);
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur && onBlur(event);
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
setValue(value);
|
||||
onChangeText && onChangeText(value);
|
||||
},
|
||||
[onChangeText, setValue]
|
||||
);
|
||||
|
||||
const onClick = (e) => {
|
||||
props.onclick(e);
|
||||
};
|
||||
const labelMoved = isFocused || value !== '';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.children}
|
||||
<div
|
||||
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||
}`}
|
||||
ref={inputRef}
|
||||
>
|
||||
<label
|
||||
className="flex space-x-2 items-center"
|
||||
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||
>
|
||||
{LeadingIcon ? (
|
||||
<div className="w-10 h-full">
|
||||
<LeadingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||
type={keyboardType}
|
||||
readOnly
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleChange}
|
||||
tabIndex="0"
|
||||
onClick={onClick}
|
||||
value={propValue}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||
>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{TrailingIcon ? (
|
||||
<div className="w-10 h-10">
|
||||
<TrailingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web-old/src/components/DebugCamera.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { h } from 'preact';
|
||||
import Link from './Link';
|
||||
import Switch from './Switch';
|
||||
import { useCallback, useMemo } from 'preact/hooks';
|
||||
import { usePersistence } from '../context';
|
||||
import AutoUpdatingCameraImage from './AutoUpdatingCameraImage';
|
||||
|
||||
const emptyObject = Object.freeze({});
|
||||
|
||||
export function DebugCamera({ camera }) {
|
||||
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
(id, value) => {
|
||||
const newOptions = { ...options, [id]: value };
|
||||
setOptions(newOptions);
|
||||
},
|
||||
[options, setOptions]
|
||||
);
|
||||
|
||||
const searchParams = useMemo(
|
||||
() =>
|
||||
new URLSearchParams(
|
||||
Object.keys(options).reduce((memo, key) => {
|
||||
memo.push([key, options[key] === true ? '1' : '0']);
|
||||
return memo;
|
||||
}, [])
|
||||
),
|
||||
[options]
|
||||
);
|
||||
|
||||
const optionContent = (
|
||||
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
|
||||
<Switch
|
||||
checked={options['bbox']}
|
||||
id='bbox'
|
||||
onChange={handleSetOption}
|
||||
label='Bounding box'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch
|
||||
checked={options['timestamp']}
|
||||
id='timestamp'
|
||||
onChange={handleSetOption}
|
||||
label='Timestamp'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch checked={options['zones']} id='zones' onChange={handleSetOption} label='Zones' labelPosition='after' />
|
||||
<Switch checked={options['mask']} id='mask' onChange={handleSetOption} label='Masks' labelPosition='after' />
|
||||
<Switch
|
||||
checked={options['motion']}
|
||||
id='motion'
|
||||
onChange={handleSetOption}
|
||||
label='Motion boxes'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Switch
|
||||
checked={options['regions']}
|
||||
id='regions'
|
||||
onChange={handleSetOption}
|
||||
label='Regions'
|
||||
labelPosition='after'
|
||||
/>
|
||||
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
|
||||
{optionContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
web-old/src/components/Dialog.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function Dialog({ children, portalRootID = 'dialogs' }) {
|
||||
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dialog = (
|
||||
<Fragment>
|
||||
<div
|
||||
data-testid="scrim"
|
||||
key="scrim"
|
||||
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||
>
|
||||
<div
|
||||
role="modal"
|
||||
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 sm:max-w-sm md:max-w-md lg:max-w-lg text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||
show ? 'scale-100 opacity-100' : ''
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||
}
|
||||
35
web-old/src/components/DialogLarge.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function LargeDialog({ children, portalRootID = 'dialogs' }) {
|
||||
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dialog = (
|
||||
<Fragment>
|
||||
<div
|
||||
data-testid="scrim"
|
||||
key="scrim"
|
||||
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||
>
|
||||
<div
|
||||
role="modal"
|
||||
className={`absolute rounded shadow-2xl bg-white w-full max-h-fit sm:max-w-md md:max-w-lg lg:max-w-xl xl:max-w-2xl dark:bg-gray-700 text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||
show ? 'scale-100 opacity-100' : ''
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||
}
|
||||
5
web-old/src/components/Heading.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Heading({ children, className = '', size = '2xl' }) {
|
||||
return <h1 className={`font-semibold tracking-widest uppercase text-${size} ${className}`}>{children}</h1>;
|
||||
}
|
||||
30
web-old/src/components/HistoryViewer/HistoryHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
|
||||
interface HistoryHeaderProps {
|
||||
event?: TimelineEvent;
|
||||
className?: string;
|
||||
}
|
||||
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {
|
||||
let title = 'No Event Found';
|
||||
let subtitle = <span>Event was not found at marker position.</span>;
|
||||
if (event) {
|
||||
const { startTime, endTime, label } = event;
|
||||
const thisMorning = new Date();
|
||||
thisMorning.setHours(0, 0, 0);
|
||||
const isToday = endTime.getTime() > thisMorning.getTime();
|
||||
title = label;
|
||||
subtitle = (
|
||||
<span>
|
||||
{isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} ·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<div>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
web-old/src/components/HistoryViewer/HistoryVideo.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useApiHost } from '../../api';
|
||||
import { isNullOrUndefined } from '../../utils/objectUtils';
|
||||
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
import videojs from 'video.js';
|
||||
import type Player from 'video.js/dist/types/player';
|
||||
|
||||
interface OnTimeUpdateEvent {
|
||||
timestamp: number;
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
interface HistoryVideoProps {
|
||||
id?: string;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
onTimeUpdate?: (event: OnTimeUpdateEvent) => void;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
export const HistoryVideo = ({
|
||||
id,
|
||||
isPlaying: videoIsPlaying,
|
||||
currentTime,
|
||||
onTimeUpdate,
|
||||
onPause,
|
||||
onPlay,
|
||||
}: HistoryVideoProps) => {
|
||||
const apiHost = useApiHost();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [video, setVideo] = useState<Player>();
|
||||
|
||||
useEffect(() => {
|
||||
let video: Player
|
||||
if (videoRef.current) {
|
||||
video = videojs(videoRef.current, {})
|
||||
setVideo(video)
|
||||
}
|
||||
() => video?.dispose()
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!id) {
|
||||
video.pause()
|
||||
return
|
||||
}
|
||||
|
||||
video.src({
|
||||
src: `${apiHost}vod/event/${id}/master.m3u8`,
|
||||
type: 'application/vnd.apple.mpegurl',
|
||||
});
|
||||
video.poster(`${apiHost}api/events/${id}/snapshot.jpg`);
|
||||
if (videoIsPlaying) {
|
||||
video.play();
|
||||
}
|
||||
}, [video, id, apiHost, videoIsPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
const hasSeeked = currentTime >= 0;
|
||||
if (video && videoExists && hasSeeked) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime, videoRef]);
|
||||
|
||||
const onTimeUpdateHandler = useCallback(
|
||||
(event: Event) => {
|
||||
const target = event.target as HTMLMediaElement;
|
||||
const timeUpdateEvent = {
|
||||
isPlaying: videoIsPlaying,
|
||||
timestamp: target.currentTime,
|
||||
};
|
||||
onTimeUpdate && onTimeUpdate(timeUpdateEvent);
|
||||
},
|
||||
[videoIsPlaying, onTimeUpdate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (video && (video as any).readyState() >= 1) {
|
||||
if (videoIsPlaying) {
|
||||
video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
}, [video, videoIsPlaying])
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (video && (video as any).readyState() >= 1 && videoIsPlaying) {
|
||||
video.play()
|
||||
}
|
||||
}, [video, videoIsPlaying])
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
<video
|
||||
ref={videoRef}
|
||||
onTimeUpdate={onTimeUpdateHandler}
|
||||
onLoadedMetadata={onLoad}
|
||||
onPause={onPause}
|
||||
onPlay={onPlay}
|
||||
className="video-js vjs-fluid"
|
||||
data-setup="{}"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
web-old/src/components/HistoryViewer/HistoryViewer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import useSWR from 'swr';
|
||||
import axios from 'axios';
|
||||
import Timeline from '../Timeline/Timeline';
|
||||
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 }: {camera: string}) {
|
||||
const searchParams = {
|
||||
before: null,
|
||||
after: null,
|
||||
camera,
|
||||
label: 'all',
|
||||
zone: 'all',
|
||||
};
|
||||
|
||||
// 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) {
|
||||
const filteredEvents = [...events].reverse().filter((e) => e.end_time !== undefined);
|
||||
setTimelineEvents(filteredEvents);
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
const handleTimelineChange = useCallback(
|
||||
(event: TimelineChangeEvent) => {
|
||||
if (event.seekComplete) {
|
||||
setCurrentEvent(event.timelineEvent);
|
||||
|
||||
if (isPlaying && event.timelineEvent) {
|
||||
const eventTime = (event.markerTime.getTime() - event.timelineEvent.startTime.getTime()) / 1000;
|
||||
setCurrentTime(eventTime);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isPlaying]
|
||||
);
|
||||
|
||||
const onPlayHandler = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const onPausedHandler = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const onPlayPauseHandler = (isPlaying: boolean) => {
|
||||
setIsPlaying(isPlaying);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Fragment>
|
||||
<div className='relative flex flex-col'>
|
||||
<Fragment>
|
||||
<HistoryHeader event={currentEvent} className='mb-2' />
|
||||
<HistoryVideo
|
||||
id={currentEvent ? currentEvent.id : undefined}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
onPlay={onPlayHandler}
|
||||
onPause={onPausedHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<Timeline
|
||||
events={timelineEvents}
|
||||
isPlaying={isPlaying}
|
||||
onChange={handleTimelineChange}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
37
web-old/src/components/JSMpegPlayer.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { h } from 'preact';
|
||||
import { baseUrl } from '../api/baseUrl';
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
|
||||
export default function JSMpegPlayer({ camera, width, height }) {
|
||||
const playerRef = useRef();
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}live/jsmpeg/${camera}`;
|
||||
|
||||
useEffect(() => {
|
||||
const video = new JSMpeg.VideoElement(
|
||||
playerRef.current,
|
||||
url,
|
||||
{},
|
||||
{protocols: [], audio: false, videoBufferSize: 1024*1024*4}
|
||||
);
|
||||
|
||||
const fullscreen = () => {
|
||||
if (video.els.canvas.webkitRequestFullScreen) {
|
||||
video.els.canvas.webkitRequestFullScreen();
|
||||
}
|
||||
else {
|
||||
video.els.canvas.mozRequestFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
video.els.canvas.addEventListener('click',fullscreen);
|
||||
|
||||
return () => {
|
||||
video.destroy();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div ref={playerRef} class="jsmpeg" style={`max-height: ${height}px; max-width: ${width}px`} />
|
||||
);
|
||||
}
|
||||
16
web-old/src/components/Link.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { h } from 'preact';
|
||||
import { Link as RouterLink } from 'preact-router/match';
|
||||
|
||||
export default function Link({
|
||||
activeClassName = '',
|
||||
className = 'text-blue-500 hover:underline',
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<RouterLink activeClassName={activeClassName} className={className} href={href} {...props}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
16
web-old/src/components/LinkedLogo.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from './Heading';
|
||||
import Logo from './Logo';
|
||||
|
||||
export default function LinkedLogo() {
|
||||
return (
|
||||
<Heading size="lg">
|
||||
<a className="transition-colors flex items-center space-x-4 dark:text-white hover:text-blue-500" href="/">
|
||||
<div className="w-10">
|
||||
<Logo />
|
||||
</div>
|
||||
Frigate
|
||||
</a>
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
18
web-old/src/components/LiveChip.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export function LiveChip({ className }) {
|
||||
return (
|
||||
<div className={`inline relative px-2 py-1 rounded-full ${className}`}>
|
||||
<div className='relative inline-block w-3 h-3 mr-2'>
|
||||
<span class='flex h-3 w-3'>
|
||||
<span
|
||||
class='animate-ping absolute inline-flex h-full w-full rounded-full opacity-75'
|
||||
style={{ backgroundColor: 'rgb(74 222 128)' }}
|
||||
/>
|
||||
<span class='relative inline-flex rounded-full h-3 w-3' style={{ backgroundColor: 'rgb(74 222 128)' }} />
|
||||
</span>
|
||||
</div>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
web-old/src/components/Logo.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Logo() {
|
||||
return (
|
||||
<svg viewBox="0 0 512 512" className="fill-current">
|
||||
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.5 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
48
web-old/src/components/Menu.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { h } from 'preact';
|
||||
import RelativeModal from './RelativeModal';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
|
||||
export default function Menu({ className, children, onDismiss, relativeTo, widthRelative }) {
|
||||
return relativeTo ? (
|
||||
<RelativeModal
|
||||
children={children}
|
||||
className={`${className || ''} py-2`}
|
||||
role="listbox"
|
||||
onDismiss={onDismiss}
|
||||
portalRootID="menus"
|
||||
relativeTo={relativeTo}
|
||||
widthRelative={widthRelative}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<Icon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="whitespace-nowrap">{label}</div>
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSeparator() {
|
||||
return <div className="border-b border-gray-200 dark:border-gray-800 my-2" />;
|
||||
}
|
||||
649
web-old/src/components/MsePlayer.js
Normal file
@@ -0,0 +1,649 @@
|
||||
class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 30000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = 'webrtc,mse,mp4,mjpeg';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
this.video = null;
|
||||
|
||||
/**
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
*/
|
||||
this.pc = null;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* [internal] Handlers list for receiving JSON from WebSocket
|
||||
* @type {Object.<string,Function>}}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = `ws${value.substring(4)}`;
|
||||
} else if (value.startsWith('/')) {
|
||||
value = `ws${location.origin.substring(4)}${value}`;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video. Support automute when autoplay blocked.
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch((er) => {
|
||||
if (er.name === 'NotAllowedError' && !this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(() => { });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server via WebSocket
|
||||
* @param {Object} value
|
||||
*/
|
||||
send(value) {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
if (this.video) {
|
||||
const seek = this.video.seekable;
|
||||
if (seek.length > 0) {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
}
|
||||
this.play();
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
this.video.muted = true;
|
||||
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: this.visibilityThreshold }
|
||||
);
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', (ev) => this.onopen(ev));
|
||||
this.ws.addEventListener('close', (ev) => this.onclose(ev));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
// iPhone
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||
// macOS Desktop app
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = (msg) => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
};
|
||||
} else {
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
/** @type {MediaSource} */
|
||||
let ms;
|
||||
|
||||
if ('ManagedMediaSource' in window) {
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.disableRemotePlayback = true;
|
||||
this.video.srcObject = ms;
|
||||
} else {
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage['mse'] = (msg) => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (sb.updating) return;
|
||||
|
||||
try {
|
||||
if (bufLen > 0) {
|
||||
const data = buf.slice(0, bufLen);
|
||||
bufLen = 0;
|
||||
sb.appendBuffer(data);
|
||||
} else if (sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
|
||||
const start = sb.buffered.start(0);
|
||||
if (end > start) {
|
||||
sb.remove(start, end);
|
||||
ms.setLiveSeekableRange(end, end + 15);
|
||||
}
|
||||
// console.debug("VideoRTC.buffered", start, end);
|
||||
}
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||
let bufLen = 0;
|
||||
|
||||
this.ondata = (data) => {
|
||||
if (sb.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
bufLen += b.byteLength;
|
||||
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
|
||||
} else {
|
||||
try {
|
||||
sb.appendBuffer(data);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true });
|
||||
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({ type: 'webrtc/candidate', value: candidate });
|
||||
});
|
||||
|
||||
pc.addEventListener('track', (ev) => {
|
||||
// when stream already init
|
||||
if (video2.srcObject !== null) return;
|
||||
|
||||
// when audio track not exist in Chrome
|
||||
if (ev.streams.length === 0) return;
|
||||
|
||||
// when audio track not exist in Firefox
|
||||
if (ev.streams[0].id[0] === '{') return;
|
||||
|
||||
video2.srcObject = ev.streams[0];
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage['webrtc'] = (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
pc.addIceCandidate({
|
||||
candidate: msg.value,
|
||||
sdpMid: '0',
|
||||
}).catch(() => { });
|
||||
break;
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: msg.value,
|
||||
}).catch(() => { });
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari doesn't support "offerToReceiveVideo"
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
pc.createOffer().then((offer) => {
|
||||
pc.setLocalDescription(offer).then(() => {
|
||||
this.send({ type: 'webrtc/offer', value: offer.sdp });
|
||||
});
|
||||
});
|
||||
|
||||
this.pcState = WebSocket.CONNECTING;
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ev {Event}
|
||||
*/
|
||||
onpcvideo(ev) {
|
||||
if (!this.pc) return;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = ev.target;
|
||||
const state = this.pc.connectionState;
|
||||
|
||||
// Firefox doesn't support pc.connectionState
|
||||
if (state === 'connected' || state === 'connecting' || !state) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0,
|
||||
msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const ms = video2.srcObject;
|
||||
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = ms;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = (data) => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = `data:image/jpeg;base64,${VideoRTC.btoa(data)}`;
|
||||
};
|
||||
|
||||
this.send({ type: 'mjpeg' });
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener('loadeddata', (_) => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = (data) => {
|
||||
video2.src = `data:video/mp4;base64,${VideoRTC.btoa(data)}`;
|
||||
};
|
||||
|
||||
this.send({ type: 'mp4', value: this.codecs(this.video.canPlayType) });
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoStream extends VideoRTC {
|
||||
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
super.oninit();
|
||||
const info = this.querySelector('.info');
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = 'loading';
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {;
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage['stream'] = (_) => {
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = 'RTC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('video-stream', VideoStream);
|
||||
70
web-old/src/components/MultiSelect.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import Menu from './Menu';
|
||||
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||
import Heading from './Heading';
|
||||
import Button from './Button';
|
||||
import SelectOnlyIcon from '../icons/SelectOnly';
|
||||
|
||||
export default function MultiSelect({ className, title, options, selection, onToggle, onShowAll, onSelectSingle }) {
|
||||
const popupRef = useRef(null);
|
||||
|
||||
const [state, setState] = useState({
|
||||
showMenu: false,
|
||||
});
|
||||
|
||||
const isOptionSelected = (item) => {
|
||||
return selection == 'all' || selection.split(',').indexOf(item) > -1;
|
||||
};
|
||||
|
||||
const menuHeight = Math.round(window.innerHeight * 0.55);
|
||||
return (
|
||||
<div className={`${className} p-2`} ref={popupRef}>
|
||||
<div className="flex justify-between min-w-[120px]" onClick={() => setState({ showMenu: true })}>
|
||||
<label>{title}</label>
|
||||
<ArrowDropdown className="w-6" />
|
||||
</div>
|
||||
{state.showMenu ? (
|
||||
<Menu
|
||||
className={`max-h-[${menuHeight}px] overflow-auto`}
|
||||
relativeTo={popupRef}
|
||||
onDismiss={() => setState({ showMenu: false })}
|
||||
>
|
||||
<div className="flex flex-wrap justify-between items-center">
|
||||
<Heading className="p-4 justify-center" size="md">
|
||||
{title}
|
||||
</Heading>
|
||||
<Button tabindex="false" className="mx-4" onClick={() => onShowAll()}>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
{options.map((item) => (
|
||||
<div className="flex flex-grow" key={item}>
|
||||
<label
|
||||
className={`flex flex-shrink space-x-2 p-1 my-1 min-w-[176px] hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer capitalize text-sm`}
|
||||
>
|
||||
<input
|
||||
className="mx-4 m-0 align-middle"
|
||||
type="checkbox"
|
||||
checked={isOptionSelected(item)}
|
||||
onChange={() => onToggle(item)}
|
||||
/>
|
||||
{item.replaceAll('_', ' ')}
|
||||
</label>
|
||||
<div className="justify-right">
|
||||
<Button
|
||||
color={isOptionSelected(item) ? 'blue' : 'black'}
|
||||
type="text"
|
||||
className="max-h-[35px] mx-2"
|
||||
onClick={() => onSelectSingle(item)}
|
||||
>
|
||||
{ ( <SelectOnlyIcon /> ) }
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
web-old/src/components/NavigationDrawer.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { Link } from 'preact-router/match';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { useDrawer } from '../context';
|
||||
|
||||
export default function NavigationDrawer({ children, header }) {
|
||||
const { showDrawer, setShowDrawer } = useDrawer();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowDrawer(false);
|
||||
}, [setShowDrawer]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{showDrawer ? <div data-testid="scrim" key="scrim" className="fixed inset-0 z-20" onClick={handleDismiss} /> : ''}
|
||||
<div
|
||||
key="drawer"
|
||||
data-testid="drawer"
|
||||
className={`fixed left-0 top-0 bottom-0 lg:sticky max-h-screen flex flex-col w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-900 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 shadow lg:shadow-none z-20 lg:z-0 transform ${
|
||||
!showDrawer ? '-translate-x-full lg:translate-x-0' : 'translate-x-0'
|
||||
} transition-transform duration-300`}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
{header ? (
|
||||
<div className="flex-shrink-0 p-2 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav className="flex flex-col flex-grow overflow-hidden overflow-y-auto p-2 space-y-2">{children}</nav>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function Destination({ className = '', href, text, ...other }) {
|
||||
const external = href.startsWith('http');
|
||||
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
|
||||
|
||||
const { setShowDrawer } = useDrawer();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setShowDrawer(false);
|
||||
}, 250);
|
||||
}, [setShowDrawer]);
|
||||
|
||||
const styleProps = {
|
||||
[external
|
||||
? 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',
|
||||
};
|
||||
|
||||
const El = external ? 'a' : Link;
|
||||
|
||||
return (
|
||||
<El activeClassName="bg-blue-500 bg-opacity-50 text-white" {...styleProps} href={href} {...props} {...other}>
|
||||
<div onClick={handleDismiss}>{text}</div>
|
||||
</El>
|
||||
);
|
||||
}
|
||||
|
||||
export function Separator() {
|
||||
return <div className="border-b border-gray-200 dark:border-gray-700 -mx-2" />;
|
||||
}
|
||||
22
web-old/src/components/Prompt.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './Button';
|
||||
import Heading from './Heading';
|
||||
import Dialog from './Dialog';
|
||||
|
||||
export default function Prompt({ actions = [], title, text }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<div className='p-4'>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
<div className='p-2 flex justify-start flex-row-reverse space-x-2'>
|
||||
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||
<Button className='ml-2' color={color} key={i} onClick={onClick} type='text' {...props}>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
176
web-old/src/components/RecordingPlaylist.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
import {
|
||||
getUnixTime,
|
||||
fromUnixTime,
|
||||
format,
|
||||
parseISO,
|
||||
intervalToDuration,
|
||||
formatDuration,
|
||||
endOfDay,
|
||||
startOfDay,
|
||||
isSameDay,
|
||||
} from 'date-fns';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Link from '../components/Link';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Menu from '../icons/Menu';
|
||||
import MenuOpen from '../icons/MenuOpen';
|
||||
import { useApiHost } from '../api';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function RecordingPlaylist({ camera, recordings, selectedDate }) {
|
||||
const [active, setActive] = useState(true);
|
||||
const toggle = () => setActive(!active);
|
||||
|
||||
const result = [];
|
||||
for (const recording of recordings) {
|
||||
const date = parseISO(recording.day);
|
||||
result.push(
|
||||
<ExpandableList
|
||||
title={format(date, 'MMM d, yyyy')}
|
||||
events={recording.events}
|
||||
selected={isSameDay(date, selectedDate)}
|
||||
>
|
||||
<DayOfEvents camera={camera} day={recording.day} hours={recording.hours} />
|
||||
</ExpandableList>
|
||||
);
|
||||
}
|
||||
|
||||
const openClass = active ? '-left-6' : 'right-0';
|
||||
|
||||
return (
|
||||
<div className="flex absolute inset-y-0 right-0 w-9/12 md:w-1/2 lg:w-3/5 max-w-md text-base text-white font-sans">
|
||||
<div
|
||||
onClick={toggle}
|
||||
className={`absolute ${openClass} cursor-pointer items-center self-center rounded-tl-lg rounded-bl-lg border border-r-0 w-6 h-20 py-7 bg-gray-800 bg-opacity-70 z-10`}
|
||||
>
|
||||
{active ? <Menu /> : <MenuOpen />}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full h-full bg-gray-800 bg-opacity-70 border-l overflow-x-hidden overflow-y-auto z-10${
|
||||
active ? '' : ' hidden'
|
||||
}`}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayOfEvents({ camera, day, hours }) {
|
||||
const date = parseISO(day);
|
||||
const { data: events } = useSWR([
|
||||
`events`,
|
||||
{
|
||||
before: getUnixTime(endOfDay(date)),
|
||||
after: getUnixTime(startOfDay(date)),
|
||||
camera,
|
||||
has_clip: '1',
|
||||
include_thumbnails: 0,
|
||||
limit: 5000,
|
||||
},
|
||||
]);
|
||||
|
||||
// maps all the events under the keys for the hour by hour recordings view
|
||||
const eventMap = useMemo(() => {
|
||||
const eventMap = {};
|
||||
for (const hour of hours) {
|
||||
eventMap[`${day}-${hour.hour}`] = [];
|
||||
}
|
||||
|
||||
if (!events) {
|
||||
return eventMap;
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const key = format(fromUnixTime(event.start_time), 'yyyy-MM-dd-HH');
|
||||
// if the hour of recordings is missing for the event start time, skip it
|
||||
if (key in eventMap) {
|
||||
eventMap[key].push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return eventMap;
|
||||
}, [events, day, hours]);
|
||||
|
||||
if (!events) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hours.map((hour, i) => (
|
||||
<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' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Link href={`/recording/${camera}/${day}/${hour.hour}`} type="text">
|
||||
{hour.hour}:00
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 text-right">{hour.events} Events</div>
|
||||
</div>
|
||||
{eventMap[`${day}-${hour.hour}`].map((event) => (
|
||||
<EventCard key={event.id} camera={camera} event={event} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpandableList({ title, events = 0, children, selected = false }) {
|
||||
const [active, setActive] = useState(selected);
|
||||
const toggle = () => setActive(!active);
|
||||
return (
|
||||
<div className={`w-full text-sm ${active ? 'border-b border-white border-opacity-50' : ''}`}>
|
||||
<div className="flex items-center w-full p-2 cursor-pointer md:text-lg" onClick={toggle}>
|
||||
<div className="flex-1 font-bold">{title}</div>
|
||||
<div className="flex-1 text-right mr-4">{events} Events</div>
|
||||
<div className="w-6 md:w-10 h-6 md:h-10">{active ? <ArrowDropup /> : <ArrowDropdown />}</div>
|
||||
</div>
|
||||
{/* Only render the child when expanded to lazy load events for the day */}
|
||||
{active && <div className={`bg-gray-800 bg-opacity-50`}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventCard({ camera, event }) {
|
||||
const apiHost = useApiHost();
|
||||
const start = fromUnixTime(event.start_time);
|
||||
const end = fromUnixTime(event.end_time);
|
||||
let duration = 'In Progress';
|
||||
if (event.end_time) {
|
||||
duration = formatDuration(intervalToDuration({ start, end }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
|
||||
<div className="flex flex-row mb-2">
|
||||
<div className="w-28 mr-4">
|
||||
<img className="antialiased" loading="lazy" src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} />
|
||||
</div>
|
||||
<div className="flex flex-row w-full border-b">
|
||||
<div className="w-full text-gray-700 font-semibold relative pt-0">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-1">
|
||||
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div>
|
||||
</div>
|
||||
<div className="text-lg text-white text-right leading-tight">
|
||||
{((event?.data?.top_score || event.top_score) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-6" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
141
web-old/src/components/RelativeModal.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const WINDOW_PADDING = 20;
|
||||
|
||||
export default function RelativeModal({
|
||||
className,
|
||||
role = 'dialog',
|
||||
children,
|
||||
onDismiss,
|
||||
portalRootID,
|
||||
relativeTo,
|
||||
widthRelative = false,
|
||||
}) {
|
||||
const [position, setPosition] = useState({ top: -9999, left: -9999 });
|
||||
const [show, setShow] = useState(false);
|
||||
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(event) => {
|
||||
onDismiss && onDismiss(event);
|
||||
},
|
||||
[onDismiss]
|
||||
);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
|
||||
if (event.key === 'Tab' && focusable.length) {
|
||||
if (event.shiftKey && document.activeElement === focusable[0]) {
|
||||
focusable[focusable.length - 1].focus();
|
||||
event.preventDefault();
|
||||
} else if (document.activeElement === focusable[focusable.length - 1]) {
|
||||
focusable[0].focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setShow(false);
|
||||
handleDismiss();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[ref, handleDismiss]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref && ref.current && relativeTo && relativeTo.current) {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const { width: menuWidth, height: menuHeight } = ref.current.getBoundingClientRect();
|
||||
const {
|
||||
x: relativeToX,
|
||||
y: relativeToY,
|
||||
width: relativeToWidth,
|
||||
height: relativeToHeight,
|
||||
} = relativeTo.current.getBoundingClientRect();
|
||||
|
||||
const _width = widthRelative ? relativeToWidth : menuWidth;
|
||||
const width = _width * 1.1;
|
||||
|
||||
const left = relativeToX + window.scrollX;
|
||||
const top = relativeToY + window.scrollY;
|
||||
|
||||
let newTop = top;
|
||||
let newLeft = left;
|
||||
|
||||
// too far left
|
||||
if (left < WINDOW_PADDING) {
|
||||
newLeft = WINDOW_PADDING;
|
||||
}
|
||||
// too far right
|
||||
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||
}
|
||||
|
||||
// This condition checks if the menu overflows the bottom of the page and
|
||||
// if there's enough space to position the menu above the clicked icon.
|
||||
// If both conditions are met, the menu will be positioned above the clicked icon
|
||||
if (
|
||||
top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY &&
|
||||
top - menuHeight - relativeToHeight >= WINDOW_PADDING
|
||||
) {
|
||||
newTop = top - menuHeight;
|
||||
}
|
||||
|
||||
if (top <= WINDOW_PADDING + window.scrollY) {
|
||||
newTop = WINDOW_PADDING;
|
||||
}
|
||||
|
||||
// This calculation checks if there's enough space below the clicked icon for the menu to fit.
|
||||
// If there is, it sets the maxHeight to null(meaning no height constraint). If not, it calculates the maxHeight based on the remaining space in the window
|
||||
const maxHeight =
|
||||
windowHeight - WINDOW_PADDING * 2 - top > menuHeight
|
||||
? null
|
||||
: windowHeight - WINDOW_PADDING * 2 - top + window.scrollY;
|
||||
|
||||
const newPosition = { left: newLeft, top: newTop, maxHeight };
|
||||
if (widthRelative) {
|
||||
newPosition.width = relativeToWidth;
|
||||
}
|
||||
setPosition(newPosition);
|
||||
const focusable = ref.current.querySelector('[tabindex]');
|
||||
focusable && focusable.focus();
|
||||
}
|
||||
}, [relativeTo, ref, widthRelative]);
|
||||
|
||||
useEffect(() => {
|
||||
if (position.top >= 0) {
|
||||
window.requestAnimationFrame(() => {
|
||||
setShow(true);
|
||||
});
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
}, [show, position, ref]);
|
||||
|
||||
const menu = (
|
||||
<Fragment>
|
||||
<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 duration-75 transform scale-90 opacity-0 overflow-x-hidden overflow-y-auto ${
|
||||
show ? 'scale-100 opacity-100' : ''
|
||||
} ${className}`}
|
||||
onKeyDown={handleKeydown}
|
||||
role={role}
|
||||
ref={ref}
|
||||
style={position}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(menu, portalRoot) : menu;
|
||||
}
|
||||
256
web-old/src/components/Select.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Menu, { MenuItem } from './Menu';
|
||||
import TextField from './TextField';
|
||||
import DatePicker from './DatePicker';
|
||||
import Calendar from './Calendar';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export default function Select({
|
||||
type,
|
||||
label,
|
||||
onChange,
|
||||
paramName,
|
||||
options: inputOptions = [],
|
||||
selected: propSelected,
|
||||
}) {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
||||
[inputOptions]
|
||||
);
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [selected, setSelected] = useState();
|
||||
const [datePickerValue, setDatePickerValue] = useState();
|
||||
|
||||
// Reset the state if the prop value changes
|
||||
useEffect(() => {
|
||||
const selectedIndex = Math.max(
|
||||
options.findIndex(({ value }) => value === propSelected),
|
||||
0
|
||||
);
|
||||
if (propSelected && selectedIndex !== selected) {
|
||||
setSelected(selectedIndex);
|
||||
setFocused(selectedIndex);
|
||||
}
|
||||
// DO NOT include `selected`
|
||||
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'datepicker') {
|
||||
if ('after' && 'before' in propSelected) {
|
||||
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
|
||||
|
||||
for (let i = 0; i < inputOptions.length; i++) {
|
||||
if (
|
||||
inputOptions[i].value &&
|
||||
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
|
||||
) {
|
||||
setDatePickerValue(inputOptions[i]?.label);
|
||||
break;
|
||||
} else {
|
||||
setDatePickerValue(
|
||||
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
|
||||
propSelected.before * 1000 - 1
|
||||
).toLocaleDateString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === 'dropdown') {
|
||||
setSelected(
|
||||
Math.max(
|
||||
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||
|
||||
const [focused, setFocused] = useState(null);
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const calendarRef = useRef(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value) => {
|
||||
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||
setShowMenu(false);
|
||||
|
||||
//show calendar date range picker
|
||||
if (value === 'custom_range') return setShowCalendar(true);
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange, options, propSelected, setSelected]
|
||||
);
|
||||
|
||||
const handleDateRange = useCallback(
|
||||
(range) => {
|
||||
onChange && onChange(range);
|
||||
setShowMenu(false);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setShowMenu(true);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const handleKeydownDatePicker = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
if (options[focused].value === 'custom_range') {
|
||||
setShowMenu(false);
|
||||
return setShowCalendar(true);
|
||||
}
|
||||
|
||||
onChange && onChange(options[focused].value);
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||
);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
onChange && onChange({ [paramName]: options[focused].value });
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowMenu(false);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const findDOMNodes = (component) => {
|
||||
return (component && (component.base || (component.nodeType === 1 && component))) || null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const addBackDrop = (e) => {
|
||||
if (showCalendar && !findDOMNodes(calendarRef.current).contains(e.target)) {
|
||||
setShowCalendar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', addBackDrop);
|
||||
|
||||
return function cleanup() {
|
||||
window.removeEventListener('click', addBackDrop);
|
||||
};
|
||||
}, [showCalendar]);
|
||||
|
||||
switch (type) {
|
||||
case 'datepicker':
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePicker
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydownDatePicker}
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={datePickerValue}
|
||||
/>
|
||||
{showCalendar && (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<Calendar onChange={handleDateRange} calendarRef={calendarRef} close={() => setShowCalendar(false)} />
|
||||
</Menu>
|
||||
)}
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
// case 'dropdown':
|
||||
default:
|
||||
return (
|
||||
<Fragment>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
readonly
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={options[selected]?.label}
|
||||
/>
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
label={label}
|
||||
focus={focused === i}
|
||||
onSelect={handleSelect}
|
||||
value={{ [paramName]: value }}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
68
web-old/src/components/Switch.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function Switch({ className, checked, id, onChange, label, labelPosition = 'before' }) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
if (onChange) {
|
||||
onChange(id, !checked);
|
||||
}
|
||||
}, [id, onChange, checked]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onChange && setFocused(true);
|
||||
}, [onChange, setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
onChange && setFocused(false);
|
||||
}, [onChange, setFocused]);
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`${className ? className : ''} flex items-center space-x-4 w-full ${onChange ? 'cursor-pointer' : 'cursor-not-allowed'}`}
|
||||
>
|
||||
{label && labelPosition === 'before' ? (
|
||||
<div data-testid={`${id}-label`} className="inline-flex flex-grow">
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
onMouseOver={handleFocus}
|
||||
onMouseOut={handleBlur}
|
||||
className={`self-end w-8 h-5 relative ${!onChange ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<input
|
||||
data-testid={`${id}-input`}
|
||||
className="absolute left-48"
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
tabIndex="0"
|
||||
id={id}
|
||||
type="checkbox"
|
||||
onChange={handleChange}
|
||||
checked={checked}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-8 h-3 absolute top-1 left-1 ${
|
||||
!checked ? 'bg-gray-300' : 'bg-blue-300'
|
||||
} rounded-full shadow-inner`}
|
||||
/>
|
||||
<div
|
||||
className={`transition-all absolute w-5 h-5 rounded-full shadow-md inset-y-0 left-0 ring-opacity-30 ${
|
||||
isFocused ? 'ring-4 ring-gray-500' : ''
|
||||
} ${checked ? 'bg-blue-600' : 'bg-white'} ${isFocused && checked ? 'ring-blue-500' : ''}`}
|
||||
style={checked ? 'transform: translateX(100%);' : 'transform: translateX(0%);'}
|
||||
/>
|
||||
</div>
|
||||
{label && labelPosition !== 'before' ? (
|
||||
<div data-testid={`${id}-label`} class="inline-flex flex-grow">
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
59
web-old/src/components/Table.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export function Table({ children, className = '' }) {
|
||||
return (
|
||||
<table className={`table-auto border-collapse text-gray-900 dark:text-gray-200 ${className}`}>{children}</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thead({ children, className, ...attrs }) {
|
||||
return (
|
||||
<thead className={className} {...attrs}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tbody({ children, className, reference, ...attrs }) {
|
||||
return (
|
||||
<tbody ref={reference} className={className} {...attrs}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tfoot({ children, className = '', ...attrs }) {
|
||||
return (
|
||||
<tfoot className={`${className}`} {...attrs}>
|
||||
{children}
|
||||
</tfoot>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tr({ children, className = '', reference, ...attrs }) {
|
||||
return (
|
||||
<tr
|
||||
ref={reference}
|
||||
className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({ children, className = '', colspan, ...attrs }) {
|
||||
return (
|
||||
<th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan} {...attrs}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ children, className = '', reference, colspan, ...attrs }) {
|
||||
return (
|
||||
<td ref={reference} className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
41
web-old/src/components/Tabs.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp);
|
||||
|
||||
const handleSelected = useCallback(
|
||||
(index) => () => {
|
||||
setSelectedIndex(index);
|
||||
onChange && onChange(index);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const RenderChildren = useCallback(() => {
|
||||
return children.map((child, i) => {
|
||||
child.props.selected = i === selectedIndex;
|
||||
child.props.onClick = handleSelected(i);
|
||||
return child;
|
||||
});
|
||||
}, [selectedIndex, children, handleSelected]);
|
||||
|
||||
return (
|
||||
<div className={`flex ${className}`}>
|
||||
<RenderChildren />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextTab({ selected, text, onClick, disabled }) {
|
||||
const selectedStyle = disabled
|
||||
? 'text-gray-400 dark:text-gray-600 bg-transparent'
|
||||
: selected
|
||||
? 'text-white bg-blue-500 dark:text-black dark:bg-white'
|
||||
: 'text-black dark:text-white bg-transparent';
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
102
web-old/src/components/TextField.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function TextField({
|
||||
helpText,
|
||||
keyboardType = 'text',
|
||||
inputRef,
|
||||
label,
|
||||
leadingIcon: LeadingIcon,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
readonly,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
}) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState(propValue);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus(event);
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur && onBlur(event);
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
setValue(value);
|
||||
onChangeText && onChangeText(value);
|
||||
},
|
||||
[onChangeText, setValue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (propValue !== value) {
|
||||
setValue(propValue);
|
||||
}
|
||||
// DO NOT include `value`
|
||||
}, [propValue, setValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const labelMoved = isFocused || value !== '';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||
}`}
|
||||
ref={inputRef}
|
||||
>
|
||||
<label
|
||||
className="flex space-x-2 items-center"
|
||||
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||
>
|
||||
{LeadingIcon ? (
|
||||
<div className="w-10 h-full">
|
||||
<LeadingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleChange}
|
||||
readOnly={readonly}
|
||||
tabIndex="0"
|
||||
type={keyboardType}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{TrailingIcon ? (
|
||||
<div className="w-10 h-10">
|
||||
<TrailingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
web-old/src/components/TimeAgo.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { h, FunctionComponent } from 'preact';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
|
||||
interface IProp {
|
||||
/** The time to calculate time-ago from */
|
||||
time: Date;
|
||||
/** OPTIONAL: overwrite current time */
|
||||
currentTime?: Date;
|
||||
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
|
||||
dense?: boolean;
|
||||
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
type TimeUnit = {
|
||||
unit: string;
|
||||
full: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
|
||||
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
|
||||
|
||||
const pastTime: Date = new Date(time);
|
||||
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
|
||||
|
||||
const timeUnits: TimeUnit[] = [
|
||||
{ unit: 'yr', full: 'year', value: 31536000 },
|
||||
{ unit: 'mo', full: 'month', value: 0 },
|
||||
{ unit: 'd', full: 'day', value: 86400 },
|
||||
{ unit: 'h', full: 'hour', value: 3600 },
|
||||
{ unit: 'm', full: 'minute', value: 60 },
|
||||
{ unit: 's', full: 'second', value: 1 },
|
||||
];
|
||||
|
||||
const elapsed: number = elapsedTime / 1000;
|
||||
if (elapsed < 10) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
for (let i = 0; i < timeUnits.length; i++) {
|
||||
// if months
|
||||
if (i === 1) {
|
||||
// Get the month and year for the time provided
|
||||
const pastMonth = pastTime.getUTCMonth();
|
||||
const pastYear = pastTime.getUTCFullYear();
|
||||
|
||||
// get current month and year
|
||||
const currentMonth = currentTime.getUTCMonth();
|
||||
const currentYear = currentTime.getUTCFullYear();
|
||||
|
||||
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
|
||||
|
||||
// check if the time provided is the previous month but not exceeded 1 month ago.
|
||||
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
|
||||
monthDiff--;
|
||||
}
|
||||
|
||||
if (monthDiff > 0) {
|
||||
const unitAmount = monthDiff;
|
||||
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||
}
|
||||
} else if (elapsed >= timeUnits[i].value) {
|
||||
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
|
||||
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||
}
|
||||
}
|
||||
return 'Invalid Time';
|
||||
};
|
||||
|
||||
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
|
||||
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||
useEffect(() => {
|
||||
const intervalId: NodeJS.Timeout = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, refreshInterval);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [refreshInterval]);
|
||||
|
||||
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
|
||||
|
||||
return <span>{timeAgoValue}</span>;
|
||||
};
|
||||
export default TimeAgo;
|
||||
103
web-old/src/components/TimePicker.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowDropdown } from '../icons/ArrowDropdown';
|
||||
import { ArrowDropup } from '../icons/ArrowDropup';
|
||||
import Heading from './Heading';
|
||||
|
||||
const TimePicker = ({ timeRange, onChange }) => {
|
||||
const times = timeRange.split(',');
|
||||
const [after, setAfter] = useState(times[0]);
|
||||
const [before, setBefore] = useState(times[1]);
|
||||
|
||||
// Create repeating array with the number of hours for 1 day ...23,24,0,1,2...
|
||||
const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0'));
|
||||
|
||||
// background colors for each day
|
||||
function randomGrayTone(shade) {
|
||||
const grayTones = [
|
||||
'bg-[#212529]/50',
|
||||
'bg-[#343a40]/50',
|
||||
'bg-[#495057]/50',
|
||||
'bg-[#666463]/50',
|
||||
'bg-[#817D7C]/50',
|
||||
'bg-[#73706F]/50',
|
||||
'bg-[#585655]/50',
|
||||
'bg-[#4F4D4D]/50',
|
||||
'bg-[#454343]/50',
|
||||
'bg-[#363434]/50',
|
||||
];
|
||||
return grayTones[shade % grayTones.length];
|
||||
}
|
||||
|
||||
const isSelected = (idx, current) => {
|
||||
return current == `${idx}:00`;
|
||||
};
|
||||
|
||||
const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
|
||||
const handleTime = (after, before) => {
|
||||
setAfter(after);
|
||||
setBefore(before);
|
||||
onChange(`${after},${before}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowDropup className="w-10 text-center" />
|
||||
</div>
|
||||
<div className="px-1 flex justify-between">
|
||||
<div>
|
||||
<Heading className="text-center" size="sm">
|
||||
After
|
||||
</Heading>
|
||||
<div
|
||||
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
||||
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
||||
>
|
||||
{hoursInDays.map((time, idx) => (
|
||||
<div className={`${isSelected(time, after) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
|
||||
<div
|
||||
className={`
|
||||
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
|
||||
${randomGrayTone([Math.floor(idx / 24)])}`}
|
||||
onClick={() => handleTime(`${time}:00`, before)}
|
||||
>
|
||||
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Heading className="text-center" size="sm">
|
||||
Before
|
||||
</Heading>
|
||||
<div
|
||||
className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
|
||||
style={{ maxHeight: '17rem', overflowY: 'scroll' }}
|
||||
>
|
||||
{hoursInDays.map((time, idx) => (
|
||||
<div className={`${isSelected(time, before) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
|
||||
<div
|
||||
className={`
|
||||
text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
|
||||
${randomGrayTone([Math.floor(idx / 24)])}`}
|
||||
onClick={() => handleTime(after, `${time}:00`)}
|
||||
>
|
||||
<span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowDropdown className="w-10 text-center" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePicker;
|
||||
4
web-old/src/components/Timeline/ScrollPermission.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ScrollPermission {
|
||||
allowed: boolean;
|
||||
resetAfterSeeked: boolean;
|
||||
}
|
||||
245
web-old/src/components/Timeline/Timeline.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils';
|
||||
import type { ScrollPermission } from './ScrollPermission';
|
||||
import { TimelineBlocks } from './TimelineBlocks';
|
||||
import type { TimelineChangeEvent } from './TimelineChangeEvent';
|
||||
import { DisabledControls, TimelineControls } from './TimelineControls';
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
isPlaying: boolean;
|
||||
onChange: (event: TimelineChangeEvent) => void;
|
||||
onPlayPause?: (isPlaying: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) {
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
|
||||
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
|
||||
playPause: false,
|
||||
next: true,
|
||||
previous: false,
|
||||
});
|
||||
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>();
|
||||
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
|
||||
const scrollToPosition = useCallback(
|
||||
(positionX: number) => {
|
||||
if (timelineContainerRef.current) {
|
||||
const permission: ScrollPermission = {
|
||||
allowed: true,
|
||||
resetAfterSeeked: true,
|
||||
};
|
||||
setScrollPermission(permission);
|
||||
timelineContainerRef.current.scroll({
|
||||
left: positionX,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
[timelineContainerRef]
|
||||
);
|
||||
|
||||
const scrollToEvent = useCallback(
|
||||
(event: TimelineEventBlock, offset = 0) => {
|
||||
scrollToPosition(event.positionX + offset - timelineOffset);
|
||||
},
|
||||
[timelineOffset, scrollToPosition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeline.length > 0 && currentEvent) {
|
||||
const currentIndex = currentEvent.index;
|
||||
if (currentIndex === 0) {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
next: false,
|
||||
previous: true,
|
||||
}));
|
||||
} else if (currentIndex === timeline.length - 1) {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
previous: false,
|
||||
next: true,
|
||||
}));
|
||||
} else {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
previous: false,
|
||||
next: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [timeline, currentEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && timelineOffset) {
|
||||
const timelineEvents = getTimelineEventBlocksFromTimelineEvents(events, timelineOffset);
|
||||
const lastEventIndex = timelineEvents.length - 1;
|
||||
const recentEvent = timelineEvents[lastEventIndex];
|
||||
|
||||
setTimeline(timelineEvents);
|
||||
setMarkerTime(recentEvent.startTime);
|
||||
setCurrentEvent(recentEvent);
|
||||
scrollToEvent(recentEvent);
|
||||
}
|
||||
}, [events, timelineOffset, scrollToEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineIsLoaded = timeline.length > 0;
|
||||
if (timelineIsLoaded) {
|
||||
const lastEvent = timeline[timeline.length - 1];
|
||||
scrollToEvent(lastEvent);
|
||||
}
|
||||
}, [timeline, scrollToEvent]);
|
||||
|
||||
const checkMarkerForEvent = (markerTime: Date) => {
|
||||
const adjustedMarkerTime = new Date(markerTime);
|
||||
adjustedMarkerTime.setSeconds(markerTime.getSeconds() + 1);
|
||||
|
||||
return [...timeline]
|
||||
.reverse()
|
||||
.find(
|
||||
(timelineEvent) =>
|
||||
timelineEvent.startTime.getTime() <= adjustedMarkerTime.getTime() &&
|
||||
timelineEvent.endTime.getTime() >= adjustedMarkerTime.getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const seekCompleteHandler = (markerTime: Date) => {
|
||||
if (scrollPermission.allowed) {
|
||||
const markerEvent = checkMarkerForEvent(markerTime);
|
||||
setCurrentEvent(markerEvent);
|
||||
|
||||
onChange({
|
||||
markerTime,
|
||||
timelineEvent: markerEvent,
|
||||
seekComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (scrollPermission.resetAfterSeeked) {
|
||||
setScrollPermission({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const waitForSeekComplete = (markerTime: Date) => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150));
|
||||
};
|
||||
|
||||
const onTimelineScrollHandler = () => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const currentMarkerTime = getCurrentMarkerTime();
|
||||
setMarkerTime(currentMarkerTime);
|
||||
waitForSeekComplete(currentMarkerTime);
|
||||
onChange({
|
||||
timelineEvent: currentEvent,
|
||||
markerTime: currentMarkerTime,
|
||||
seekComplete: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentMarkerTime = useCallback(() => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const scrollPosition = timelineContainerRef.current.scrollLeft;
|
||||
const firstTimelineEvent = timeline[0] as TimelineEventBlock;
|
||||
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
|
||||
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
|
||||
}
|
||||
return new Date();
|
||||
}, [timeline, timelineContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current?.offsetWidth || 0;
|
||||
const offset = Math.round(timelineContainerWidth / 2);
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
}, [timelineContainerRef]);
|
||||
|
||||
const handleViewEvent = useCallback(
|
||||
(event: TimelineEventBlock) => {
|
||||
scrollToEvent(event);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
},
|
||||
[scrollToEvent, getCurrentMarkerTime]
|
||||
);
|
||||
|
||||
const onPlayPauseHandler = (isPlaying: boolean) => {
|
||||
onPlayPause && onPlayPause(isPlaying);
|
||||
};
|
||||
|
||||
const onPreviousHandler = () => {
|
||||
if (currentEvent) {
|
||||
const previousEvent = timeline[currentEvent.index - 1];
|
||||
setCurrentEvent(previousEvent);
|
||||
scrollToEvent(previousEvent);
|
||||
}
|
||||
};
|
||||
const onNextHandler = () => {
|
||||
if (currentEvent) {
|
||||
const nextEvent = timeline[currentEvent.index + 1];
|
||||
setCurrentEvent(nextEvent);
|
||||
scrollToEvent(nextEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const timelineBlocks = useMemo(() => {
|
||||
if (timelineOffset && timeline.length > 0) {
|
||||
return <TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />;
|
||||
}
|
||||
}, [timeline, timelineOffset, handleViewEvent]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='flex-grow-1'>
|
||||
<div className='w-full text-center'>
|
||||
<span className='text-black dark:text-white'>
|
||||
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='absolute left-0 top-0 h-full w-full text-center'>
|
||||
<div className='h-full text-center' style={{ margin: '0 auto' }}>
|
||||
<div
|
||||
className='z-20 h-full absolute'
|
||||
style={{
|
||||
left: 'calc(100% / 2)',
|
||||
borderRight: '2px solid rgba(252, 211, 77)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
{timelineBlocks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineControls
|
||||
disabled={disabledControls}
|
||||
isPlaying={isPlaying}
|
||||
onPrevious={onPreviousHandler}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
onNext={onNextHandler}
|
||||
className='mt-2'
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
25
web-old/src/components/Timeline/TimelineBlockView.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlockViewProps {
|
||||
block: TimelineEventBlock;
|
||||
onClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlockView = ({ block, onClick }: TimelineBlockViewProps) => {
|
||||
const onClickHandler = useCallback(() => onClick(block), [block, onClick]);
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
onClick={onClickHandler}
|
||||
className={`absolute z-10 rounded-full ${getColorFromTimelineEvent(block)} h-2`}
|
||||
style={{
|
||||
top: `${block.yOffset}px`,
|
||||
left: `${block.positionX}px`,
|
||||
width: `${block.width}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
48
web-old/src/components/Timeline/TimelineBlocks.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { h } from 'preact';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
|
||||
import { convertRemToPixels } from '../../utils/windowUtils';
|
||||
import { TimelineBlockView } from './TimelineBlockView';
|
||||
import type { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlocksProps {
|
||||
timeline: TimelineEventBlock[];
|
||||
firstBlockOffset: number;
|
||||
onEventClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => {
|
||||
const timelineEventBlocks = useMemo(() => {
|
||||
if (timeline.length > 0 && firstBlockOffset) {
|
||||
const largestYOffsetInBlocks = findLargestYOffsetInBlocks(timeline);
|
||||
const timelineContainerHeight = largestYOffsetInBlocks + convertRemToPixels(1);
|
||||
const timelineContainerWidth = getTimelineWidthFromBlocks(timeline, firstBlockOffset);
|
||||
const timelineBlockOffset = (timelineContainerHeight - largestYOffsetInBlocks) / 2;
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `${timelineContainerHeight}px`,
|
||||
width: `${timelineContainerWidth}px`,
|
||||
background: "url('/images/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '30px',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
{timeline.map((block) => {
|
||||
const onClickHandler = (block: TimelineEventBlock) => onEventClick(block);
|
||||
const updatedBlock: TimelineEventBlock = {
|
||||
...block,
|
||||
yOffset: block.yOffset + timelineBlockOffset,
|
||||
};
|
||||
return <TimelineBlockView key={block.id} block={updatedBlock} onClick={onClickHandler} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [timeline, onEventClick, firstBlockOffset]);
|
||||
|
||||
return timelineEventBlocks;
|
||||
};
|
||||
7
web-old/src/components/Timeline/TimelineChangeEvent.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent?: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
||||
45
web-old/src/components/Timeline/TimelineControls.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { h } from 'preact';
|
||||
import Next from '../../icons/Next';
|
||||
import Pause from '../../icons/Pause';
|
||||
import Play from '../../icons/Play';
|
||||
import Previous from '../../icons/Previous';
|
||||
import { BubbleButton } from '../BubbleButton';
|
||||
|
||||
export interface DisabledControls {
|
||||
playPause: boolean;
|
||||
next: boolean;
|
||||
previous: boolean;
|
||||
}
|
||||
|
||||
interface TimelineControlsProps {
|
||||
disabled: DisabledControls;
|
||||
className?: string;
|
||||
isPlaying: boolean;
|
||||
onPlayPause: (isPlaying: boolean) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
|
||||
export const TimelineControls = ({
|
||||
disabled,
|
||||
isPlaying,
|
||||
onPlayPause,
|
||||
onNext,
|
||||
onPrevious,
|
||||
className = '',
|
||||
}: TimelineControlsProps) => {
|
||||
const onPlayClickHandler = () => {
|
||||
onPlayPause(!isPlaying);
|
||||
};
|
||||
return (
|
||||
<div className={`flex space-x-2 self-center ${className}`}>
|
||||
<BubbleButton variant='secondary' onClick={onPrevious} disabled={disabled.previous}>
|
||||
<Previous />
|
||||
</BubbleButton>
|
||||
<BubbleButton onClick={onPlayClickHandler}>{!isPlaying ? <Play /> : <Pause />}</BubbleButton>
|
||||
<BubbleButton variant='secondary' onClick={onNext} disabled={disabled.next}>
|
||||
<Next />
|
||||
</BubbleButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
web-old/src/components/Timeline/TimelineEvent.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
id: string;
|
||||
label: 'car' | 'person' | 'dog';
|
||||
}
|
||||
9
web-old/src/components/Timeline/TimelineEventBlock.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
positionX: number;
|
||||
seconds: number;
|
||||
}
|
||||
65
web-old/src/components/TimelineEventOverlay.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
export default function TimelineEventOverlay({ eventOverlay, cameraConfig }) {
|
||||
const boxLeftEdge = Math.round(eventOverlay.data.box[0] * 100);
|
||||
const boxTopEdge = Math.round(eventOverlay.data.box[1] * 100);
|
||||
const boxRightEdge = Math.round((1 - eventOverlay.data.box[2] - eventOverlay.data.box[0]) * 100);
|
||||
const boxBottomEdge = Math.round((1 - eventOverlay.data.box[3] - eventOverlay.data.box[1]) * 100);
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const getHoverStyle = () => {
|
||||
if (boxLeftEdge < 15) {
|
||||
// show object stats on right side
|
||||
return {
|
||||
left: `${boxLeftEdge + eventOverlay.data.box[2] * 100 + 1}%`,
|
||||
top: `${boxTopEdge}%`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
right: `${boxRightEdge + eventOverlay.data.box[2] * 100 + 1}%`,
|
||||
top: `${boxTopEdge}%`,
|
||||
};
|
||||
};
|
||||
|
||||
const getObjectArea = () => {
|
||||
const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
|
||||
const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
|
||||
return Math.round(width * height);
|
||||
};
|
||||
|
||||
const getObjectRatio = () => {
|
||||
const width = eventOverlay.data.box[2] * cameraConfig.detect.width;
|
||||
const height = eventOverlay.data.box[3] * cameraConfig.detect.height;
|
||||
return Math.round(100 * (width / height)) / 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className="absolute border-4 border-red-600"
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onTouchStart={() => setIsHovering(true)}
|
||||
onTouchEnd={() => setIsHovering(false)}
|
||||
style={{
|
||||
left: `${boxLeftEdge}%`,
|
||||
top: `${boxTopEdge}%`,
|
||||
right: `${boxRightEdge}%`,
|
||||
bottom: `${boxBottomEdge}%`,
|
||||
}}
|
||||
>
|
||||
{eventOverlay.class_type == 'entered_zone' ? (
|
||||
<div className="absolute w-2 h-2 bg-yellow-500 left-[50%] -translate-x-1/2 translate-y-3/4 bottom-0" />
|
||||
) : null}
|
||||
</div>
|
||||
{isHovering && (
|
||||
<div className="absolute bg-white dark:bg-slate-800 p-4 block text-black dark:text-white text-lg" style={getHoverStyle()}>
|
||||
<div>{`Area: ${getObjectArea()} px`}</div>
|
||||
<div>{`Ratio: ${getObjectRatio()}`}</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
218
web-old/src/components/TimelineSummary.jsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { h } from 'preact';
|
||||
import useSWR from 'swr';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import { formatUnixTimestampToDateTime } from '../utils/dateUtil';
|
||||
import About from '../icons/About';
|
||||
import ActiveObjectIcon from '../icons/ActiveObject';
|
||||
import PlayIcon from '../icons/Play';
|
||||
import ExitIcon from '../icons/Exit';
|
||||
import StationaryObjectIcon from '../icons/StationaryObject';
|
||||
import FaceIcon from '../icons/Face';
|
||||
import LicensePlateIcon from '../icons/LicensePlate';
|
||||
import DeliveryTruckIcon from '../icons/DeliveryTruck';
|
||||
import ZoneIcon from '../icons/Zone';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import Button from './Button';
|
||||
|
||||
export default function TimelineSummary({ event, onFrameSelected }) {
|
||||
const { data: eventTimeline } = useSWR([
|
||||
'timeline',
|
||||
{
|
||||
source_id: event.id,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
const annotationOffset = useMemo(() => {
|
||||
if (!config) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (config.cameras[event.camera]?.detect?.annotation_offset || 0) / 1000;
|
||||
}, [config, event]);
|
||||
|
||||
const [timeIndex, setTimeIndex] = useState(-1);
|
||||
|
||||
const recordingParams = useMemo(() => {
|
||||
if (!event.end_time) {
|
||||
return {
|
||||
after: event.start_time,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
before: event.end_time,
|
||||
after: event.start_time,
|
||||
};
|
||||
}, [event]);
|
||||
const { data: recordings } = useSWR([`${event.camera}/recordings`, recordingParams], { revalidateOnFocus: false });
|
||||
|
||||
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
|
||||
const getSeekSeconds = (seekUnix) => {
|
||||
if (!recordings) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let seekSeconds = 0;
|
||||
recordings.every((segment) => {
|
||||
// if the next segment is past the desired time, stop calculating
|
||||
if (segment.start_time > seekUnix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (segment.end_time < seekUnix) {
|
||||
seekSeconds += segment.end_time - segment.start_time;
|
||||
return true;
|
||||
}
|
||||
|
||||
seekSeconds += segment.end_time - segment.start_time - (segment.end_time - seekUnix);
|
||||
return true;
|
||||
});
|
||||
|
||||
return seekSeconds;
|
||||
};
|
||||
|
||||
const onSelectMoment = async (index) => {
|
||||
setTimeIndex(index);
|
||||
onFrameSelected(eventTimeline[index], getSeekSeconds(eventTimeline[index].timestamp + annotationOffset));
|
||||
};
|
||||
|
||||
if (!eventTimeline || !config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
if (eventTimeline.length == 0) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="h-14 flex justify-center">
|
||||
<div className="flex flex-row flex-nowrap justify-between overflow-auto">
|
||||
{eventTimeline.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
className="rounded-full"
|
||||
type="iconOnly"
|
||||
color={index == timeIndex ? 'blue' : 'gray'}
|
||||
aria-label={window.innerWidth > 640 ? getTimelineItemDescription(config, item, event) : ''}
|
||||
onClick={() => onSelectMoment(index)}
|
||||
>
|
||||
{getTimelineIcon(item)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{timeIndex >= 0 ? (
|
||||
<div className="m-2 max-w-md self-center">
|
||||
<div className="flex justify-start">
|
||||
<div className="text-lg flex justify-between py-4">Bounding boxes may not align</div>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
type="text"
|
||||
color="gray"
|
||||
aria-label=" Disclaimer: This data comes from the detect feed but is shown on the recordings, it is unlikely that the
|
||||
streams are perfectly in sync so the bounding box and the footage will not line up perfectly. The annotation_offset field can be used to adjust this."
|
||||
>
|
||||
<About className="w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimelineIcon(timelineItem) {
|
||||
switch (timelineItem.class_type) {
|
||||
case 'visible':
|
||||
return <PlayIcon className="w-8" />;
|
||||
case 'gone':
|
||||
return <ExitIcon className="w-8" />;
|
||||
case 'active':
|
||||
return <ActiveObjectIcon className="w-8" />;
|
||||
case 'stationary':
|
||||
return <StationaryObjectIcon className="w-8" />;
|
||||
case 'entered_zone':
|
||||
return <ZoneIcon className="w-8" />;
|
||||
case 'attribute':
|
||||
switch (timelineItem.data.attribute) {
|
||||
case 'face':
|
||||
return <FaceIcon className="w-8" />;
|
||||
case 'license_plate':
|
||||
return <LicensePlateIcon className="w-8" />;
|
||||
default:
|
||||
return <DeliveryTruckIcon className="w-8" />;
|
||||
}
|
||||
case 'sub_label':
|
||||
switch (timelineItem.data.label) {
|
||||
case 'person':
|
||||
return <FaceIcon className="w-8" />;
|
||||
case 'car':
|
||||
return <LicensePlateIcon className="w-8" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTimelineItemDescription(config, timelineItem, event) {
|
||||
switch (timelineItem.class_type) {
|
||||
case 'visible':
|
||||
return `${event.label} detected at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
})}`;
|
||||
case 'entered_zone':
|
||||
return `${event.label.replaceAll('_', ' ')} entered ${timelineItem.data.zones
|
||||
.join(' and ')
|
||||
.replaceAll('_', ' ')} at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
})}`;
|
||||
case 'active':
|
||||
return `${event.label} became active at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
})}`;
|
||||
case 'stationary':
|
||||
return `${event.label} became stationary at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
})}`;
|
||||
case 'attribute': {
|
||||
let title = "";
|
||||
if (timelineItem.data.attribute == 'face' || timelineItem.data.attribute == 'license_plate') {
|
||||
title = `${timelineItem.data.attribute.replaceAll("_", " ")} detected for ${event.label}`;
|
||||
} else {
|
||||
title = `${event.label} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`
|
||||
}
|
||||
return `${title} at ${formatUnixTimestampToDateTime(
|
||||
timelineItem.timestamp,
|
||||
{
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
}
|
||||
)}`;
|
||||
}
|
||||
case 'sub_label':
|
||||
return `${event.label} recognized as ${timelineItem.data.sub_label} at ${formatUnixTimestampToDateTime(
|
||||
timelineItem.timestamp,
|
||||
{
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
}
|
||||
)}`;
|
||||
case 'gone':
|
||||
return `${event.label} left at ${formatUnixTimestampToDateTime(timelineItem.timestamp, {
|
||||
date_style: 'short',
|
||||
time_style: 'medium',
|
||||
time_format: config.ui.time_format,
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
63
web-old/src/components/Tooltip.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { h } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const TIP_SPACE = 20;
|
||||
|
||||
export default function Tooltip({ relativeTo, text, capitalize }) {
|
||||
const [position, setPosition] = useState({ top: -9999, left: -9999 });
|
||||
const portalRoot = document.getElementById('tooltips');
|
||||
const ref = useRef();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref && ref.current && relativeTo && relativeTo.current) {
|
||||
const windowWidth = window.innerWidth;
|
||||
const {
|
||||
x: relativeToX,
|
||||
y: relativeToY,
|
||||
width: relativeToWidth,
|
||||
height: relativeToHeight,
|
||||
} = relativeTo.current.getBoundingClientRect();
|
||||
const { width: _tipWidth, height: _tipHeight } = ref.current.getBoundingClientRect();
|
||||
const tipWidth = _tipWidth * 1.1;
|
||||
const tipHeight = _tipHeight * 1.1;
|
||||
|
||||
const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
|
||||
const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
|
||||
|
||||
let newTop = top - TIP_SPACE - tipHeight;
|
||||
let newLeft = left - Math.round(tipWidth / 2);
|
||||
// too far right
|
||||
if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
|
||||
newLeft = Math.max(0, left - tipWidth - TIP_SPACE);
|
||||
newTop = top - Math.round(tipHeight / 2);
|
||||
}
|
||||
// too far left
|
||||
else if (newLeft < TIP_SPACE + window.scrollX) {
|
||||
newLeft = left + TIP_SPACE;
|
||||
newTop = top - Math.round(tipHeight / 2);
|
||||
}
|
||||
// too close to top
|
||||
else if (newTop <= TIP_SPACE + window.scrollY) {
|
||||
newTop = top + tipHeight + TIP_SPACE;
|
||||
}
|
||||
|
||||
setPosition({ left: newLeft, top: newTop });
|
||||
}
|
||||
}, [relativeTo, ref]);
|
||||
|
||||
const tooltip = (
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-transform transition-opacity duration-75 transform scale-90 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
|
||||
capitalize ? 'capitalize' : ''
|
||||
} ${position.top >= 0 ? 'opacity-100 scale-100' : ''}`}
|
||||
ref={ref}
|
||||
style={position}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
|
||||
}
|
||||
97
web-old/src/components/VideoPlayer.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
import videojs from 'video.js';
|
||||
import 'videojs-playlist';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = () => {}, onDispose = () => {} }) {
|
||||
const playerRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const defaultOptions = {
|
||||
controls: true,
|
||||
controlBar: {
|
||||
skipButtons: seekOptions,
|
||||
},
|
||||
playbackRates: [0.5, 1, 2, 4, 8],
|
||||
fluid: true,
|
||||
};
|
||||
|
||||
|
||||
if (!videojs.browser.IS_FIREFOX) {
|
||||
defaultOptions.playbackRates.push(16);
|
||||
}
|
||||
|
||||
const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
|
||||
onReady(player);
|
||||
});
|
||||
|
||||
// Allows player to continue on error
|
||||
player.reloadSourceOnError();
|
||||
|
||||
// Disable fullscreen on iOS if we have children
|
||||
if (
|
||||
children &&
|
||||
videojs.browser.IS_IOS &&
|
||||
videojs.browser.IOS_VERSION > 9 &&
|
||||
!player.el_.ownerDocument.querySelector('.bc-iframe')
|
||||
) {
|
||||
player.tech_.el_.setAttribute('playsinline', 'playsinline');
|
||||
player.tech_.supportsFullScreen = function () {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
const screen = window.screen;
|
||||
|
||||
const angle = () => {
|
||||
// iOS
|
||||
if (typeof window.orientation === 'number') {
|
||||
return window.orientation;
|
||||
}
|
||||
// Android
|
||||
if (screen && screen.orientation && screen.orientation.angle) {
|
||||
return window.orientation;
|
||||
}
|
||||
videojs.log('angle unknown');
|
||||
return 0;
|
||||
};
|
||||
|
||||
const rotationHandler = () => {
|
||||
const currentAngle = angle();
|
||||
|
||||
if (currentAngle === 90 || currentAngle === 270 || currentAngle === -90) {
|
||||
if (player.paused() === false) {
|
||||
player.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
if ((currentAngle === 0 || currentAngle === 180) && player.isFullscreen()) {
|
||||
player.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
if (videojs.browser.IS_IOS) {
|
||||
window.addEventListener('orientationchange', rotationHandler);
|
||||
} else if (videojs.browser.IS_ANDROID && screen.orientation) {
|
||||
// addEventListener('orientationchange') is not a user interaction on Android
|
||||
screen.orientation.onchange = rotationHandler;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (videojs.browser.IS_IOS) {
|
||||
window.removeEventListener('orientationchange', rotationHandler);
|
||||
}
|
||||
player.dispose();
|
||||
onDispose();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
{/* Setting an empty data-setup is required to override the default values and allow video to be fit the size of its parent */}
|
||||
<video ref={playerRef} className="small-player video-js vjs-default-skin" data-setup="{}" controls playsinline />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
web-old/src/components/WebRtcPlayer.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { h } from 'preact';
|
||||
import { baseUrl } from '../api/baseUrl';
|
||||
import { useCallback, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function WebRtcPlayer({ camera, width, height }) {
|
||||
const PeerConnection = useCallback(async (media) => {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
});
|
||||
|
||||
const localTracks = [];
|
||||
|
||||
if (/camera|microphone/.test(media)) {
|
||||
const tracks = await getMediaTracks('user', {
|
||||
video: media.indexOf('camera') >= 0,
|
||||
audio: media.indexOf('microphone') >= 0,
|
||||
});
|
||||
tracks.forEach((track) => {
|
||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (media.indexOf('display') >= 0) {
|
||||
const tracks = await getMediaTracks('display', {
|
||||
video: true,
|
||||
audio: media.indexOf('speaker') >= 0,
|
||||
});
|
||||
tracks.forEach((track) => {
|
||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (/video|audio/.test(media)) {
|
||||
const tracks = ['video', 'audio']
|
||||
.filter((kind) => media.indexOf(kind) >= 0)
|
||||
.map((kind) => pc.addTransceiver(kind, { direction: 'recvonly' }).receiver.track);
|
||||
localTracks.push(...tracks);
|
||||
}
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream(localTracks);
|
||||
|
||||
return pc;
|
||||
}, []);
|
||||
|
||||
async function getMediaTracks(media, constraints) {
|
||||
try {
|
||||
const stream =
|
||||
media === 'user'
|
||||
? await navigator.mediaDevices.getUserMedia(constraints)
|
||||
: await navigator.mediaDevices.getDisplayMedia(constraints);
|
||||
return stream.getTracks();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(async (ws, aPc) => {
|
||||
const pc = await aPc;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
if (!ev.candidate) return;
|
||||
const msg = { type: 'webrtc/candidate', value: ev.candidate.candidate };
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer))
|
||||
.then(() => {
|
||||
const msg = { type: 'webrtc/offer', value: pc.localDescription.sdp };
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'webrtc/candidate') {
|
||||
pc.addIceCandidate({ candidate: msg.value, sdpMid: '0' });
|
||||
} else if (msg.type === 'webrtc/answer') {
|
||||
pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=${camera}`;
|
||||
const ws = new WebSocket(url);
|
||||
const aPc = PeerConnection('video+audio');
|
||||
connect(ws, aPc);
|
||||
|
||||
return async () => {
|
||||
(await aPc).close();
|
||||
}
|
||||
}, [camera, connect, PeerConnection]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<video id="video" autoplay playsinline controls muted width={width} height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
web-old/src/components/__tests__/ActivityIndicator.test.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../ActivityIndicator';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('ActivityIndicator', () => {
|
||||
test('renders an ActivityIndicator with default size md', async () => {
|
||||
render(<ActivityIndicator />);
|
||||
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="w-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-8 w-8 border-4 border-t-4"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('renders an ActivityIndicator with size sm', async () => {
|
||||
render(<ActivityIndicator size="sm" />);
|
||||
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="w-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-4 w-4 border-2 border-t-2"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('renders an ActivityIndicator with size lg', async () => {
|
||||
render(<ActivityIndicator size="lg" />);
|
||||
expect(screen.getByLabelText('Loading…')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="w-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="activityindicator ease-in rounded-full border-gray-200 text-blue-500 h-16 w-16 border-8 border-t-8"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
||||
132
web-old/src/components/__tests__/AppBar.test.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { h } from 'preact';
|
||||
import { DrawerProvider } from '../../context';
|
||||
import AppBar from '../AppBar';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
function Title() {
|
||||
return <div>I am the title</div>;
|
||||
}
|
||||
|
||||
describe('AppBar', () => {
|
||||
test('renders the title', async () => {
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.getByText('I am the title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('overflow menu', () => {
|
||||
test('is not rendered if a ref is not provided', async () => {
|
||||
const handleOverflow = vi.fn();
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} onOverflowClick={handleOverflow} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('is not rendered if a click handler is not provided', async () => {
|
||||
function Wrapper() {
|
||||
const ref = useRef(null);
|
||||
return <AppBar title={Title} overflowRef={ref} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<Wrapper />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('is rendered with click handler and ref', async () => {
|
||||
const handleOverflow = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
const ref = useRef(null);
|
||||
return <AppBar title={Title} overflowRef={ref} onOverflowClick={handleOverflow} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<Wrapper />
|
||||
</DrawerProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText('More options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls the handler when clicked', async () => {
|
||||
const handleOverflow = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
const ref = useRef(null);
|
||||
return <AppBar title={Title} overflowRef={ref} onOverflowClick={handleOverflow} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<Wrapper />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.queryByLabelText('More options'));
|
||||
|
||||
expect(handleOverflow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling', () => {
|
||||
test('is visible initially', async () => {
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
const classes = screen.getByTestId('appbar').classList;
|
||||
|
||||
expect(classes.contains('translate-y-0')).toBe(true);
|
||||
expect(classes.contains('-translate-y-full')).toBe(false);
|
||||
});
|
||||
|
||||
test('hides when scrolled downward', async () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
window.scrollY = 300;
|
||||
await fireEvent.scroll(document, { target: { scrollY: 300 } });
|
||||
|
||||
const classes = screen.getByTestId('appbar').classList;
|
||||
|
||||
expect(classes.contains('translate-y-0')).toBe(false);
|
||||
expect(classes.contains('-translate-y-full')).toBe(true);
|
||||
});
|
||||
|
||||
test('reappears when scrolled upward', async () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
|
||||
render(
|
||||
<DrawerProvider>
|
||||
<AppBar title={Title} />
|
||||
</DrawerProvider>
|
||||
);
|
||||
|
||||
window.scrollY = 300;
|
||||
await fireEvent.scroll(document, { target: { scrollY: 300 } });
|
||||
window.scrollY = 280;
|
||||
await fireEvent.scroll(document, { target: { scrollY: 280 } });
|
||||
|
||||
const classes = screen.getByTestId('appbar').classList;
|
||||
|
||||
expect(classes.contains('translate-y-0')).toBe(true);
|
||||
expect(classes.contains('-translate-y-full')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { h } from 'preact';
|
||||
import AutoUpdatingCameraImage from '../AutoUpdatingCameraImage';
|
||||
import { screen, render } from '@testing-library/preact';
|
||||
|
||||
let mockOnload;
|
||||
vi.mock('../CameraImage', () => {
|
||||
function CameraImage({ onload, searchParams }) {
|
||||
mockOnload = () => {
|
||||
onload();
|
||||
};
|
||||
return <div data-testid="camera-image">{searchParams}</div>;
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CameraImage,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AutoUpdatingCameraImage', () => {
|
||||
let dateNowSpy;
|
||||
beforeEach(() => {
|
||||
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(0);
|
||||
});
|
||||
|
||||
test('shows FPS by default', async () => {
|
||||
render(<AutoUpdatingCameraImage camera="tacos" />);
|
||||
expect(screen.queryByText('Displaying at 0fps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show FPS if turned off', async () => {
|
||||
render(<AutoUpdatingCameraImage camera="tacos" showFps={false} />);
|
||||
expect(screen.queryByText('Displaying at 0fps')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('on load, sets a new cache key to search params', async () => {
|
||||
dateNowSpy.mockReturnValueOnce(100).mockReturnValueOnce(200).mockReturnValueOnce(300);
|
||||
render(<AutoUpdatingCameraImage camera="front" searchParams="foo" />);
|
||||
mockOnload();
|
||||
await screen.findByText('cache=100&foo');
|
||||
expect(screen.getByText('cache=100&foo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
36
web-old/src/components/__tests__/Button.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { h } from 'preact';
|
||||
import Button from '../Button';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Button', () => {
|
||||
test('renders children', async () => {
|
||||
render(
|
||||
<Button>
|
||||
<div>hello</div>
|
||||
<div>hi</div>
|
||||
</Button>
|
||||
);
|
||||
expect(screen.queryByText('hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('hi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('includes focus, active, and hover classes when enabled', async () => {
|
||||
render(<Button>click me</Button>);
|
||||
|
||||
const classList = screen.queryByRole('button').classList;
|
||||
expect(classList.contains('focus:outline-none')).toBe(true);
|
||||
expect(classList.contains('focus:ring-2')).toBe(true);
|
||||
expect(classList.contains('hover:shadow-md')).toBe(true);
|
||||
expect(classList.contains('active:bg-blue-600')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not focus, active, and hover classes when enabled', async () => {
|
||||
render(<Button disabled>click me</Button>);
|
||||
|
||||
const classList = screen.queryByRole('button').classList;
|
||||
expect(classList.contains('focus:outline-none')).toBe(false);
|
||||
expect(classList.contains('focus:ring-2')).toBe(false);
|
||||
expect(classList.contains('hover:shadow-md')).toBe(false);
|
||||
expect(classList.contains('active:bg-blue-600')).toBe(false);
|
||||
});
|
||||
});
|
||||
15
web-old/src/components/__tests__/CameraImage.test.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { h } from 'preact';
|
||||
import * as Hooks from '../../hooks';
|
||||
import CameraImage from '../CameraImage';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('CameraImage', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
|
||||
});
|
||||
|
||||
test('renders an activity indicator while loading', async () => {
|
||||
render(<CameraImage camera="front" />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
46
web-old/src/components/__tests__/Card.test.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { h } from 'preact';
|
||||
import Card from '../Card';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Card', () => {
|
||||
test('renders a Card with media', async () => {
|
||||
render(<Card media={<img src="tacos.jpg" alt="tacos" />} />);
|
||||
expect(screen.queryByAltText('tacos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a Card with a link around media', async () => {
|
||||
render(<Card href="/tacos" media={<img src="tacos.jpg" alt="tacos" />} />);
|
||||
expect(screen.queryByAltText('tacos')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('tacos').closest('a')).toHaveAttribute('href', '/tacos');
|
||||
});
|
||||
|
||||
test('renders a Card with a header', async () => {
|
||||
render(<Card header="Tacos!" />);
|
||||
expect(screen.queryByText('Tacos!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a Card with a linked header', async () => {
|
||||
render(<Card href="/tacos" header="Tacos!" />);
|
||||
expect(screen.queryByText('Tacos!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tacos!').closest('a')).toHaveAttribute('href', '/tacos');
|
||||
});
|
||||
|
||||
test('renders content', async () => {
|
||||
const content = <div data-testid="content">hello</div>;
|
||||
render(<Card content={content} />);
|
||||
expect(screen.queryByTestId('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders buttons', async () => {
|
||||
const buttons = [
|
||||
{ name: 'Tacos', href: '/tacos' },
|
||||
{ name: 'Burritos', href: '/burritos' },
|
||||
];
|
||||
render(<Card buttons={buttons} />);
|
||||
expect(screen.queryByText('Tacos')).toHaveAttribute('role', 'button');
|
||||
expect(screen.queryByText('Tacos')).toHaveAttribute('href', '/tacos');
|
||||
|
||||
expect(screen.queryByText('Burritos')).toHaveAttribute('role', 'button');
|
||||
expect(screen.queryByText('Burritos')).toHaveAttribute('href', '/burritos');
|
||||
});
|
||||
});
|
||||
23
web-old/src/components/__tests__/Dialog.test.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { h } from 'preact';
|
||||
import Dialog from '../Dialog';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Dialog', () => {
|
||||
let portal;
|
||||
|
||||
beforeAll(() => {
|
||||
portal = document.createElement('div');
|
||||
portal.id = 'dialogs';
|
||||
document.body.appendChild(portal);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
test('renders to a portal', async () => {
|
||||
render(<Dialog>Sample</Dialog>);
|
||||
expect(screen.getByText('Sample')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
25
web-old/src/components/__tests__/Heading.test.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Heading', () => {
|
||||
test('renders content with default size', async () => {
|
||||
render(<Heading>Hello</Heading>);
|
||||
expect(screen.queryByText('Hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(true);
|
||||
});
|
||||
|
||||
test('renders with custom size', async () => {
|
||||
render(<Heading size="lg">Hello</Heading>);
|
||||
expect(screen.queryByText('Hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(false);
|
||||
expect(screen.queryByText('Hello').classList.contains('text-lg')).toBe(true);
|
||||
});
|
||||
|
||||
test('renders with custom className', async () => {
|
||||
render(<Heading className="tacos">Hello</Heading>);
|
||||
expect(screen.queryByText('Hello')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hello').classList.contains('text-2xl')).toBe(true);
|
||||
expect(screen.queryByText('Hello').classList.contains('tacos')).toBe(true);
|
||||
});
|
||||
});
|
||||
18
web-old/src/components/__tests__/Link.test.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
import Link from '../Link';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('Link', () => {
|
||||
test('renders a link', async () => {
|
||||
render(<Link href="/tacos">Hello</Link>);
|
||||
expect(screen.queryByText('Hello')).toMatchInlineSnapshot(`
|
||||
<a
|
||||
class="text-blue-500 hover:underline"
|
||||
href="/tacos"
|
||||
>
|
||||
Hello
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
});
|
||||
52
web-old/src/components/__tests__/Menu.test.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { h } from 'preact';
|
||||
import Menu, { MenuItem } from '../Menu';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
describe('Menu', () => {
|
||||
test('renders a dialog', async () => {
|
||||
function Test() {
|
||||
const relativeRef = useRef();
|
||||
return (
|
||||
<div>
|
||||
<div ref={relativeRef} />
|
||||
<Menu relativeTo={relativeRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Test />);
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MenuItem', () => {
|
||||
test('renders a menu item', async () => {
|
||||
render(<MenuItem label="Tacos" />);
|
||||
expect(screen.queryByRole('option')).toHaveTextContent('Tacos');
|
||||
});
|
||||
|
||||
test('calls onSelect when clicked', async () => {
|
||||
const handleSelect = vi.fn();
|
||||
render(<MenuItem label="Tacos" onSelect={handleSelect} value="tacos-value" />);
|
||||
fireEvent.click(screen.queryByRole('option'));
|
||||
expect(handleSelect).toHaveBeenCalledWith('tacos-value', 'Tacos');
|
||||
});
|
||||
|
||||
test('renders and icon when passed', async () => {
|
||||
function Icon() {
|
||||
return <div data-testid="icon" />;
|
||||
}
|
||||
render(<MenuItem icon={Icon} label="Tacos" />);
|
||||
expect(screen.queryByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies different styles when focused', async () => {
|
||||
const { rerender } = render(<MenuItem label="Tacos" />);
|
||||
const classes = Array.from(screen.queryByRole('option').classList);
|
||||
rerender(<MenuItem label="Tacos" focus />);
|
||||
const focusClasses = Array.from(screen.queryByRole('option').classList);
|
||||
|
||||
expect(focusClasses.length).toBeGreaterThan(classes.length);
|
||||
});
|
||||
});
|
||||
63
web-old/src/components/__tests__/NavigationDrawer.test.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { h } from 'preact';
|
||||
import * as Context from '../../context';
|
||||
import NavigationDrawer, { Destination } from '../NavigationDrawer';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('NavigationDrawer', () => {
|
||||
let useDrawer, setShowDrawer;
|
||||
|
||||
beforeEach(() => {
|
||||
setShowDrawer = vi.fn();
|
||||
useDrawer = vi.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer }));
|
||||
});
|
||||
|
||||
test('renders a navigation drawer', async () => {
|
||||
render(
|
||||
<NavigationDrawer>
|
||||
<div data-testid="children">Hello</div>
|
||||
</NavigationDrawer>
|
||||
);
|
||||
expect(screen.queryByTestId('children')).toHaveTextContent('Hello');
|
||||
expect(screen.queryByTestId('drawer').classList.contains('translate-x-full')).toBe(false);
|
||||
expect(screen.queryByTestId('drawer').classList.contains('translate-x-0')).toBe(true);
|
||||
});
|
||||
|
||||
test('is dismissed when the scrim is clicked', async () => {
|
||||
useDrawer
|
||||
.mockReturnValueOnce({ showDrawer: true, setShowDrawer })
|
||||
.mockReturnValueOnce({ showDrawer: false, setShowDrawer });
|
||||
render(<NavigationDrawer />);
|
||||
fireEvent.click(screen.queryByTestId('scrim'));
|
||||
expect(setShowDrawer).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('is not visible when not set to show', async () => {
|
||||
useDrawer.mockReturnValue({ showDrawer: false, setShowDrawer });
|
||||
render(<NavigationDrawer />);
|
||||
expect(screen.queryByTestId('scrim')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer').classList.contains('-translate-x-full')).toBe(true);
|
||||
expect(screen.queryByTestId('drawer').classList.contains('translate-x-0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination', () => {
|
||||
let setShowDrawer;
|
||||
|
||||
beforeEach(() => {
|
||||
setShowDrawer = vi.fn();
|
||||
vi.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer }));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('dismisses the drawer moments after being clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<NavigationDrawer>
|
||||
<Destination href="/tacos" text="Tacos" />
|
||||
</NavigationDrawer>
|
||||
);
|
||||
fireEvent.click(screen.queryByText('Tacos'));
|
||||
vi.runAllTimers();
|
||||
expect(setShowDrawer).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
38
web-old/src/components/__tests__/Prompt.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { h } from 'preact';
|
||||
import Prompt from '../Prompt';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Prompt', () => {
|
||||
let portal;
|
||||
|
||||
beforeAll(() => {
|
||||
portal = document.createElement('div');
|
||||
portal.id = 'dialogs';
|
||||
document.body.appendChild(portal);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
test('renders to a portal', async () => {
|
||||
render(<Prompt title="Tacos" text="This is the dialog" />);
|
||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders action buttons', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Prompt
|
||||
actions={[
|
||||
{ color: 'red', text: 'Delete' },
|
||||
{ text: 'Okay', onClick: handleClick },
|
||||
]}
|
||||
title="Tacos"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
64
web-old/src/components/__tests__/RelativeModal.test.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { h, createRef } from 'preact';
|
||||
import RelativeModal from '../RelativeModal';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('RelativeModal', () => {
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('keeps tab focus', async () => {
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<label for="outside-input">outside</label>
|
||||
<input id="outside-input" tabindex="0" />
|
||||
<div ref={ref} />
|
||||
<RelativeModal relativeTo={ref}>
|
||||
<input data-testid="modal-input-0" tabindex="0" />
|
||||
<input data-testid="modal-input-1" tabindex="0" />
|
||||
</RelativeModal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inputs = screen.queryAllByTestId(/modal-input/);
|
||||
expect(document.activeElement).toBe(inputs[0]);
|
||||
userEvent.tab();
|
||||
expect(document.activeElement).toBe(inputs[1]);
|
||||
userEvent.tab();
|
||||
expect(document.activeElement).toBe(inputs[0]);
|
||||
});
|
||||
|
||||
test('pressing ESC dismisses', async () => {
|
||||
const handleDismiss = vi.fn();
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<RelativeModal onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<input data-testid="modal-input-0" tabindex="0" />
|
||||
</RelativeModal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const dialog = screen.queryByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document.activeElement, { key: 'Escape', code: 'Escape' });
|
||||
expect(handleDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clicking a scrim dismisses', async () => {
|
||||
const handleDismiss = vi.fn();
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<RelativeModal onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<input data-testid="modal-input-0" tabindex="0" />
|
||||
</RelativeModal>
|
||||
</div>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.queryByTestId('scrim'));
|
||||
expect(handleDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
web-old/src/components/__tests__/Select.test.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { h } from 'preact';
|
||||
import Select from '../Select';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Select', () => {
|
||||
test('on focus, shows a menu', async () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['all', 'tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('textbox'));
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
|
||||
});
|
||||
|
||||
test('allows keyboard navigation', async () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
|
||||
});
|
||||
});
|
||||