forked from Github/frigate
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
57
web/.eslintrc.cjs
Normal file
57
web/.eslintrc.cjs
Normal file
@@ -0,0 +1,57 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2021: true, "vitest-globals/env": true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:prettier",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
settings: {
|
||||
jest: {
|
||||
version: 27,
|
||||
},
|
||||
},
|
||||
ignorePatterns: ["*.d.ts"],
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"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",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -22,4 +22,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.npm
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
}
|
||||
30
web/README.md
Normal file
30
web/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
@@ -1,104 +0,0 @@
|
||||
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"})
|
||||
);
|
||||
}),
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
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());
|
||||
@@ -1,24 +0,0 @@
|
||||
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 };
|
||||
16
web/components.json
Normal file
16
web/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,29 @@
|
||||
<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="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>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14279
web/package-lock.json
generated
14279
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
106
web/package.json
106
web/package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "frigate",
|
||||
"name": "web-new",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -7,56 +7,84 @@
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"axios": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.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",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.0.3",
|
||||
"lucide-react": "^0.294.0",
|
||||
"monaco-yaml": "^5.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-use-websocket": "^4.5.0",
|
||||
"recoil": "^0.7.7",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.2",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"video.js": "^8.6.1",
|
||||
"videojs-playlist": "^5.1.0",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"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",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@types/node": "^20.10.3",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react-icons": "^3.0.0",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"eslint-plugin-vitest-globals": "^1.4.0",
|
||||
"fake-indexeddb": "^4.0.1",
|
||||
"fake-indexeddb": "^5.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",
|
||||
"jsdom": "^23.0.1",
|
||||
"msw": "^2.0.10",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.0",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.3"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB |
1
web/public/vite.svg
Normal file
1
web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
53
web/src/App.tsx
Normal file
53
web/src/App.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Providers from "@/context/providers";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import Wrapper from "@/components/Wrapper";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Header from "@/components/Header";
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
import Live from "@/pages/Live";
|
||||
import History from "@/pages/History";
|
||||
import Export from "@/pages/Export";
|
||||
import Storage from "@/pages/Storage";
|
||||
import System from "@/pages/System";
|
||||
import ConfigEditor from "@/pages/ConfigEditor";
|
||||
import Logs from "@/pages/Logs";
|
||||
import NoMatch from "@/pages/NoMatch";
|
||||
import Settings from "@/pages/Settings";
|
||||
|
||||
function App() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const toggleNavbar = () => {
|
||||
setSheetOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter>
|
||||
<Wrapper>
|
||||
<Header onToggleNavbar={toggleNavbar} />
|
||||
<div className="grid grid-cols-[auto,1fr] flex-grow-1 overflow-auto">
|
||||
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} />
|
||||
<div id="pageRoot" className="overflow-x-hidden px-4 py-2 w-screen md:w-full">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/live/:camera?" element={<Live />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/export" element={<Export />} />
|
||||
<Route path="/storage" element={<Storage />} />
|
||||
<Route path="/system" element={<System />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/config" element={<ConfigEditor />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<NoMatch />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</BrowserRouter>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,86 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import LinkedLogo from './components/LinkedLogo';
|
||||
import { Match } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { ENV } from './env';
|
||||
import { useMemo } from 'preact/hooks'
|
||||
import useSWR from 'swr';
|
||||
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
const sortedCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(config.cameras)
|
||||
.filter(([_, conf]) => conf.ui.dashboard)
|
||||
.sort(([_, aConf], [__, bConf]) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const { birdseye } = config;
|
||||
|
||||
return (
|
||||
<NavigationDrawer header={<Header />}>
|
||||
<Destination href="/" text="Cameras" />
|
||||
<Match path="/cameras/:camera/:other?">
|
||||
{({ matches }) =>
|
||||
matches ? (
|
||||
<CameraSection sortedCameras={sortedCameras} />
|
||||
) : null
|
||||
}
|
||||
</Match>
|
||||
<Match path="/recording/:camera/:date?/:hour?/:seconds?">
|
||||
{({ matches }) =>
|
||||
matches ? (
|
||||
<RecordingSection sortedCameras={sortedCameras} />
|
||||
) : null
|
||||
}
|
||||
</Match>
|
||||
{birdseye?.enabled ? <Destination href="/birdseye" text="Birdseye" /> : null}
|
||||
<Destination href="/events" text="Events" />
|
||||
<Destination href="/exports" text="Exports" />
|
||||
<Separator />
|
||||
<Destination href="/storage" text="Storage" />
|
||||
<Destination href="/system" text="System" />
|
||||
<Destination href="/config" text="Config" />
|
||||
<Destination href="/logs" text="Logs" />
|
||||
<Separator />
|
||||
<div className="flex flex-grow" />
|
||||
{ENV !== 'production' ? (
|
||||
<Fragment>
|
||||
<Destination href="/styleguide" text="Style Guide" />
|
||||
<Separator />
|
||||
</Fragment>
|
||||
) : null}
|
||||
<Destination className="self-end" href="https://docs.frigate.video" text="Documentation" />
|
||||
<Destination className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
|
||||
</NavigationDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraSection({ sortedCameras }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
<div className="overflow-auto pr-2">
|
||||
{sortedCameras.map(([camera]) => (
|
||||
<Destination key={camera} href={`/cameras/${camera}`} text={camera.replaceAll('_', ' ')} />
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordingSection({ sortedCameras }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
<div className="overflow-auto pr-2">
|
||||
{sortedCameras.map(([camera, _]) => {
|
||||
return (
|
||||
<Destination
|
||||
key={camera}
|
||||
path={`/recording/${camera}/:date?/:hour?/:seconds?`}
|
||||
href={`/recording/${camera}`}
|
||||
text={camera.replaceAll('_', ' ')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const Header = memo(() => {
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
<LinkedLogo />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export const ENV = 'test';
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = {};
|
||||
@@ -1,12 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
/* eslint-disable jest/no-disabled-tests */
|
||||
import { h } from 'preact';
|
||||
import { WS as frigateWS, WsProvider, useWs } from '../ws';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { WS } from 'jest-websocket-mock';
|
||||
|
||||
function Test() {
|
||||
const { state } = useContext(frigateWS);
|
||||
return state.__connected ? (
|
||||
<div data-testid="data">
|
||||
{Object.keys(state).map((key) => (
|
||||
<div key={key} data-testid={key}>
|
||||
{JSON.stringify(state[key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const TEST_URL = 'ws://test-foo:1234/ws';
|
||||
|
||||
describe('WsProvider', () => {
|
||||
let wsClient, wsServer;
|
||||
beforeEach(async () => {
|
||||
wsClient = {
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
wsServer = new WS(TEST_URL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
test.skip('connects to the ws server', async () => {
|
||||
render(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(wsClient.args).toEqual([TEST_URL]);
|
||||
expect(screen.getByTestId('__connected')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
test.skip('receives data through useWs', async () => {
|
||||
function Test() {
|
||||
const {
|
||||
value: { payload, retain },
|
||||
connected,
|
||||
} = useWs('tacos');
|
||||
return connected ? (
|
||||
<div>
|
||||
<div data-testid="payload">{JSON.stringify(payload)}</div>
|
||||
<div data-testid="retain">{JSON.stringify(retain)}</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('payload');
|
||||
wsClient.onmessage({
|
||||
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
|
||||
});
|
||||
rerender(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
expect(screen.getByTestId('payload')).toHaveTextContent('{"yes":true}');
|
||||
expect(screen.getByTestId('retain')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
test.skip('can send values through useWs', async () => {
|
||||
function Test() {
|
||||
const { send, connected } = useWs('tacos');
|
||||
const handleClick = useCallback(() => {
|
||||
send({ yes: true });
|
||||
}, [send]);
|
||||
return connected ? <button onClick={handleClick}>click me</button> : null;
|
||||
}
|
||||
|
||||
render(
|
||||
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByRole('button');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await expect(wsClient.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false })
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('prefills the recordings/detect/snapshots state from config', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
||||
const config = {
|
||||
cameras: {
|
||||
front: {
|
||||
name: 'front',
|
||||
detect: { enabled: true },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: true },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
side: {
|
||||
name: 'side',
|
||||
detect: { enabled: false },
|
||||
record: { enabled: false },
|
||||
snapshots: { enabled: false },
|
||||
audio: { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<WsProvider config={config} wsUrl={TEST_URL}>
|
||||
<Test />
|
||||
</WsProvider>
|
||||
);
|
||||
await wsServer.connected;
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"ON","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent(
|
||||
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
cameras: {},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||
7
web/src/api/baseUrl.ts
Normal file
7
web/src/api/baseUrl.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
baseUrl?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
|
||||
@@ -1,33 +0,0 @@
|
||||
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;
|
||||
}
|
||||
48
web/src/api/index.tsx
Normal file
48
web/src/api/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import useSWR, { SWRConfig } from "swr";
|
||||
import { WsProvider } from "./ws";
|
||||
import axios from "axios";
|
||||
import { ReactNode } from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||
|
||||
type ApiProviderType = {
|
||||
children?: ReactNode;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function ApiProvider({ children, options }: ApiProviderType) {
|
||||
axios.defaults.headers.common = {
|
||||
"X-CSRF-TOKEN": 1,
|
||||
"X-CACHE-BYPASS": 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (key) => {
|
||||
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||
return axios.get(path, { params }).then((res) => res.data);
|
||||
},
|
||||
...options,
|
||||
}}
|
||||
>
|
||||
<WsWithConfig>{children}</WsWithConfig>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
type WsWithConfigType = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function WsWithConfig({ children }: WsWithConfigType) {
|
||||
const { data } = useSWR<FrigateConfig>("config");
|
||||
|
||||
return data ? <WsProvider config={data}>{children}</WsProvider> : children;
|
||||
}
|
||||
|
||||
export function useApiHost() {
|
||||
return baseUrl;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
196
web/src/api/ws.tsx
Normal file
196
web/src/api/ws.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from "react";
|
||||
import { produce, Draft } from "immer";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
type ReducerState = {
|
||||
[topic: string]: {
|
||||
lastUpdate: number;
|
||||
payload: string;
|
||||
retain: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type ReducerAction = {
|
||||
topic: string;
|
||||
payload: string;
|
||||
retain: boolean;
|
||||
};
|
||||
|
||||
const initialState: ReducerState = {
|
||||
_initial_state: {
|
||||
lastUpdate: 0,
|
||||
payload: "",
|
||||
retain: false,
|
||||
},
|
||||
};
|
||||
|
||||
type WebSocketContextProps = {
|
||||
state: ReducerState;
|
||||
readyState: ReadyState;
|
||||
sendJsonMessage: (message: any) => void;
|
||||
};
|
||||
|
||||
export const WS = createContext<WebSocketContextProps>({
|
||||
state: initialState,
|
||||
readyState: ReadyState.CLOSED,
|
||||
sendJsonMessage: () => {},
|
||||
});
|
||||
|
||||
export const useWebSocketContext = (): WebSocketContextProps => {
|
||||
const context = useContext(WS);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useWebSocketContext must be used within a WebSocketProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
function reducer(state: ReducerState, action: ReducerAction): ReducerState {
|
||||
switch (action.topic) {
|
||||
default:
|
||||
return produce(state, (draftState: Draft<ReducerState>) => {
|
||||
let parsedPayload = action.payload;
|
||||
try {
|
||||
parsedPayload = action.payload && JSON.parse(action.payload);
|
||||
} catch (e) {}
|
||||
draftState[action.topic] = {
|
||||
lastUpdate: Date.now(),
|
||||
payload: parsedPayload,
|
||||
retain: action.retain,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type WsProviderType = {
|
||||
config: FrigateConfig;
|
||||
children: ReactNode;
|
||||
wsUrl?: string;
|
||||
};
|
||||
|
||||
export function WsProvider({
|
||||
config,
|
||||
children,
|
||||
wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`,
|
||||
}: WsProviderType) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
||||
onMessage: (event) => {
|
||||
dispatch(JSON.parse(event.data));
|
||||
},
|
||||
onOpen: () => dispatch({ topic: "", payload: "", retain: false }),
|
||||
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: string, publishTopic: string) {
|
||||
const { state, readyState, sendJsonMessage } = useWebSocketContext();
|
||||
|
||||
const value = state[watchTopic] || { payload: null };
|
||||
|
||||
const send = useCallback(
|
||||
(payload: string, retain = false) => {
|
||||
if (readyState === ReadyState.OPEN) {
|
||||
sendJsonMessage({
|
||||
topic: publishTopic || watchTopic,
|
||||
payload,
|
||||
retain,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendJsonMessage, readyState, watchTopic, publishTopic]
|
||||
);
|
||||
|
||||
return { value, send };
|
||||
}
|
||||
|
||||
export function useDetectState(camera: string) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
} = useWs(`${camera}/detect/state`, `${camera}/detect/set`);
|
||||
return { payload, send };
|
||||
}
|
||||
|
||||
export function useRecordingsState(camera: string) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
} = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`);
|
||||
return { payload, send };
|
||||
}
|
||||
|
||||
export function useSnapshotsState(camera: string) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
} = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
|
||||
return { payload, send };
|
||||
}
|
||||
|
||||
export function useAudioState(camera: string) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
} = useWs(`${camera}/audio/state`, `${camera}/audio/set`);
|
||||
return { payload, send };
|
||||
}
|
||||
|
||||
export function usePtzCommand(camera: string) {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
|
||||
return { payload, send };
|
||||
}
|
||||
|
||||
export function useRestart() {
|
||||
const {
|
||||
value: { payload },
|
||||
send,
|
||||
} = useWs("restart", "restart");
|
||||
return { payload, send };
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as Routes from './routes';
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from './components/ActivityIndicator';
|
||||
import AsyncRoute from 'preact-async-route';
|
||||
import AppBar from './AppBar';
|
||||
import Cameras from './routes/Cameras';
|
||||
import { Router } from 'preact-router';
|
||||
import Sidebar from './Sidebar';
|
||||
import { DarkModeProvider, DrawerProvider } from './context';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function App() {
|
||||
const { data: config } = useSWR('config');
|
||||
const cameraComponent = config && config.ui?.use_experimental ? Routes.getCameraV2 : Routes.getCamera;
|
||||
|
||||
return (
|
||||
<DarkModeProvider>
|
||||
<DrawerProvider>
|
||||
<div data-testid="app" className="w-full">
|
||||
<AppBar />
|
||||
{!config ? (
|
||||
<div className="flex flex-grow-1 min-h-screen justify-center items-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<Sidebar />
|
||||
<div className="w-full flex-auto mt-16 min-w-0">
|
||||
<Router>
|
||||
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
||||
<AsyncRoute path="/cameras/:camera" getComponent={cameraComponent} />
|
||||
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||
<AsyncRoute path="/exports" getComponent={Routes.getExports} />
|
||||
<AsyncRoute
|
||||
path="/recording/:camera/:date?/:hour?/:minute?/:second?"
|
||||
getComponent={Routes.getRecording}
|
||||
/>
|
||||
<AsyncRoute path="/storage" getComponent={Routes.getStorage} />
|
||||
<AsyncRoute path="/system" getComponent={Routes.getSystem} />
|
||||
<AsyncRoute path="/config" getComponent={Routes.getConfig} />
|
||||
<AsyncRoute path="/logs" getComponent={Routes.getLogs} />
|
||||
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
||||
<Cameras default path="/" />
|
||||
</Router>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerProvider>
|
||||
</DarkModeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
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;
|
||||
@@ -1,257 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
305
web/src/components/Header.tsx
Normal file
305
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import Logo from "@/components/Logo";
|
||||
import {
|
||||
LuActivity,
|
||||
LuGithub,
|
||||
LuHardDrive,
|
||||
LuLifeBuoy,
|
||||
LuMenu,
|
||||
LuMoon,
|
||||
LuMoreVertical,
|
||||
LuPenSquare,
|
||||
LuRotateCw,
|
||||
LuSettings,
|
||||
LuSun,
|
||||
LuSunMoon,
|
||||
} from "react-icons/lu";
|
||||
import { IoColorPalette } from "react-icons/io5";
|
||||
import { CgDarkMode } from "react-icons/cg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Heading from "./ui/heading";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
colorSchemes,
|
||||
friendlyColorSchemeName,
|
||||
useTheme,
|
||||
} from "@/context/theme-provider";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import ActivityIndicator from "./ui/activity-indicator";
|
||||
import { useRestart } from "@/api/ws";
|
||||
|
||||
type HeaderProps = {
|
||||
onToggleNavbar: () => void;
|
||||
};
|
||||
|
||||
function Header({ onToggleNavbar }: HeaderProps) {
|
||||
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
|
||||
if (restartingSheetOpen) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown((prevCountdown) => prevCountdown - 1);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(countdownInterval);
|
||||
};
|
||||
}, [restartingSheetOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown === 0) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleForceReload = () => {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-10 lg:gap-20 justify-between pt-2 mb-2 border-b-[1px] px-4 items-center">
|
||||
<div className="flex gap-4 items-center flex-shrink-0 m-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={onToggleNavbar}
|
||||
>
|
||||
<LuMenu />
|
||||
</Button>
|
||||
<Link to="/">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="w-10 mr-5">
|
||||
<Logo />
|
||||
</div>
|
||||
<Heading as="h1">Frigate</Heading>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 md:gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<LuMoreVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-72 mr-5">
|
||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/storage">
|
||||
<DropdownMenuItem>
|
||||
<LuHardDrive className="mr-2 h-4 w-4" />
|
||||
<span>Storage</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link to="/system">
|
||||
<DropdownMenuItem>
|
||||
<LuActivity className="mr-2 h-4 w-4" />
|
||||
<span>System metrics</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="mt-3">
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/settings">
|
||||
<DropdownMenuItem>
|
||||
<LuSettings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<DropdownMenuItem>
|
||||
<LuPenSquare className="mr-2 h-4 w-4" />
|
||||
<span>Configuration editor</span>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuLabel className="mt-3">Appearance</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LuSunMoon className="mr-2 h-4 w-4" />
|
||||
<span>Dark Mode</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
{theme === "light" ? (
|
||||
<>
|
||||
<LuSun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">Light</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<LuMoon className="mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
Dark
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">Dark</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
{theme === "system" ? (
|
||||
<>
|
||||
<CgDarkMode className="mr-2 h-4 w-4 scale-100 transition-all" />
|
||||
System
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">System</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LuSunMoon className="mr-2 h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{colorSchemes.map((scheme) => (
|
||||
<DropdownMenuItem
|
||||
key={scheme}
|
||||
onClick={() => setColorScheme(scheme)}
|
||||
>
|
||||
{scheme === colorScheme ? (
|
||||
<>
|
||||
<IoColorPalette className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all" />
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</>
|
||||
) : (
|
||||
<span className="mr-2 ml-6">
|
||||
{friendlyColorSchemeName(scheme)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="mt-3">Help</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<a href="https://docs.frigate.video">
|
||||
<DropdownMenuItem>
|
||||
<LuLifeBuoy className="mr-2 h-4 w-4" />
|
||||
<span>Documentation</span>
|
||||
</DropdownMenuItem>
|
||||
</a>
|
||||
<a href="https://github.com/blakeblackshear/frigate">
|
||||
<DropdownMenuItem>
|
||||
<LuGithub className="mr-2 h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</DropdownMenuItem>
|
||||
</a>
|
||||
<DropdownMenuSeparator className="mt-3" />
|
||||
<DropdownMenuItem onClick={() => setRestartDialogOpen(true)}>
|
||||
<LuRotateCw className="mr-2 h-4 w-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{restartDialogOpen && (
|
||||
<AlertDialog
|
||||
open={restartDialogOpen}
|
||||
onOpenChange={() => setRestartDialogOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to restart Frigate?
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setRestartingSheetOpen(true);
|
||||
sendRestart("restart");
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{restartingSheetOpen && (
|
||||
<>
|
||||
<Sheet
|
||||
open={restartingSheetOpen}
|
||||
onOpenChange={() => setRestartingSheetOpen(false)}
|
||||
>
|
||||
<SheetContent
|
||||
side="top"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<ActivityIndicator />
|
||||
<SheetHeader className="mt-5 text-center">
|
||||
<SheetTitle className="text-center">
|
||||
Frigate is Restarting
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-center">
|
||||
<p>This page will reload in {countdown} seconds.</p>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Button size="lg" className="mt-5" onClick={handleForceReload}>
|
||||
Force Reload Now
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -1,5 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
|
||||
interface HistoryHeaderProps {
|
||||
event?: TimelineEvent;
|
||||
className?: string;
|
||||
}
|
||||
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {
|
||||
let title = 'No Event Found';
|
||||
let subtitle = <span>Event was not found at marker position.</span>;
|
||||
if (event) {
|
||||
const { startTime, endTime, label } = event;
|
||||
const thisMorning = new Date();
|
||||
thisMorning.setHours(0, 0, 0);
|
||||
const isToday = endTime.getTime() > thisMorning.getTime();
|
||||
title = label;
|
||||
subtitle = (
|
||||
<span>
|
||||
{isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} ·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<div>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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`} />
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Logo() {
|
||||
return (
|
||||
<svg viewBox="0 0 512 512" className="fill-current">
|
||||
@@ -1,48 +0,0 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
101
web/src/components/Sidebar.tsx
Normal file
101
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { IconType } from "react-icons";
|
||||
import { LuFileUp, LuFilm, LuLayoutDashboard, LuVideo } from "react-icons/lu";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import Logo from "./Logo";
|
||||
|
||||
const navbarLinks = [
|
||||
{
|
||||
id: 1,
|
||||
icon: LuLayoutDashboard,
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: LuVideo,
|
||||
title: "Live",
|
||||
url: "/live",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: LuFilm,
|
||||
title: "History",
|
||||
url: "/history",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: LuFileUp,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
},
|
||||
];
|
||||
|
||||
function Sidebar({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
}: {
|
||||
sheetOpen: boolean;
|
||||
setSheetOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const sidebar = (
|
||||
<aside className="sticky top-0 overflow-y-auto scrollbar-hidden py-4 lg:pt-0 flex flex-col ml-1 lg:w-56 gap-0">
|
||||
{navbarLinks.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.id}
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
url={item.url}
|
||||
onClick={() => setSheetOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:block">{sidebar}</div>
|
||||
|
||||
<Sheet
|
||||
open={sheetOpen}
|
||||
modal={false}
|
||||
onOpenChange={() => setSheetOpen(false)}
|
||||
>
|
||||
<SheetContent side="left" className="w-[120px]">
|
||||
<div className="w-full flex flex-row justify-center">
|
||||
<div className="w-10">
|
||||
<Logo />
|
||||
</div>
|
||||
</div>
|
||||
{sidebar}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarItemProps = {
|
||||
Icon: IconType;
|
||||
title: string;
|
||||
url: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function SidebarItem({ Icon, title, url, onClick }: SidebarItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={url}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
`py-4 px-2 flex flex-col lg:flex-row items-center rounded-lg gap-2 lg:w-full hover:bg-border ${
|
||||
isActive ? "font-bold bg-popover text-popover-foreground" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-6 h-6 mr-1" />
|
||||
<div className="text-sm">{title}</div>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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;
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ScrollPermission {
|
||||
allowed: boolean;
|
||||
resetAfterSeeked: boolean;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent?: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
id: string;
|
||||
label: 'car' | 'person' | 'dog';
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
positionX: number;
|
||||
seconds: number;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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,
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
11
web/src/components/Wrapper.tsx
Normal file
11
web/src/components/Wrapper.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type TWrapperProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: TWrapperProps) => {
|
||||
return <main className="flex flex-col max-h-screen">{children}</main>;
|
||||
};
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,47 +0,0 @@
|
||||
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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user