Use new UI (#8983)

* fixup build

* swap frontends
This commit is contained in:
Blake Blackshear
2023-12-16 16:20:59 +00:00
parent a2c6f45454
commit bdebb99b5a
286 changed files with 20010 additions and 20007 deletions

42
web-old/.eslintrc Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"trailingComma": "es5",
"singleQuote": true
}

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

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

View File

@@ -0,0 +1,24 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { ApiProvider } from '../src/api';
const Wrapper = ({ children }) => {
return (
<ApiProvider
options={{
dedupingInterval: 0,
provider: () => new Map(),
}}
>
{children}
</ApiProvider>
);
};
const customRender = (ui, options) => render(ui, { wrapper: Wrapper, ...options });
// re-export everything
export * from '@testing-library/preact';
// override render method
export { customRender as render };

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

BIN
web-old/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web-old/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

26
web-old/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

62
web-old/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

19
web-old/site.webmanifest Normal file
View 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
View 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
View 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>
);
});

View File

@@ -0,0 +1 @@
export const ENV = 'test';

View File

@@ -0,0 +1 @@
module.exports = {};

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

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

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

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

View 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: {},
};

View File

@@ -0,0 +1 @@
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;

33
web-old/src/api/index.jsx Normal file
View 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
View 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
View 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
View 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>
);
}

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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()} &middot;
</span>
);
}
return (
<div className={`text-center ${className}`}>
<Heading size='lg'>{title}</Heading>
<div>{subtitle}</div>
</div>
);
};

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

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

View 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`} />
);
}

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

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

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

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

View 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" />;
}

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

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

View 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" />;
}

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export interface ScrollPermission {
allowed: boolean;
resetAfterSeeked: boolean;
}

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

View 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`,
}}
/>
);
};

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

View File

@@ -0,0 +1,7 @@
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent?: TimelineEvent;
markerTime: Date;
seekComplete: boolean;
}

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

View File

@@ -0,0 +1,8 @@
export interface TimelineEvent {
start_time: number;
end_time: number;
startTime: Date;
endTime: Date;
id: string;
label: 'car' | 'person' | 'dog';
}

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

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

View 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,
})}`;
}
}

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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