switch to vite
@ -29,16 +29,11 @@
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"eslint.workingDirectories": ["./web"],
|
||||
"[json][jsonc]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsx][js][tsx][ts]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.addMissingImports",
|
||||
"source.fixAll.eslint"
|
||||
]
|
||||
"editor.codeActionsOnSave": ["source.addMissingImports", "source.fixAll"],
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"cSpell.ignoreWords": ["rtmp"],
|
||||
"cSpell.words": ["preact"]
|
||||
|
4
.github/workflows/pull_request.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
- run: npm install
|
||||
working-directory: ./web
|
||||
- name: Lint
|
||||
run: npm run lint:cmd
|
||||
run: npm run lint
|
||||
working-directory: ./web
|
||||
|
||||
web_build:
|
||||
@ -44,7 +44,7 @@ jobs:
|
||||
- name: Test
|
||||
run: npm run test
|
||||
working-directory: ./web
|
||||
|
||||
|
||||
docker_tests_on_aarch64:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
@ -1 +0,0 @@
|
||||
node_modules
|
@ -1,3 +1,2 @@
|
||||
build/*
|
||||
node_modules/*
|
||||
src/env.js
|
||||
dist/*
|
||||
node_modules/*
|
28
web/.eslintrc
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "preact", "prettier"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"indent": ["error", 2, { "SwitchCase": 1 }],
|
||||
"comma-dangle": ["error", { "objects": "always-multiline", "arrays": "always-multiline" }],
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{ts,tsx}"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
|
||||
}
|
||||
]
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
module.exports = {
|
||||
parser: '@babel/eslint-parser',
|
||||
extends: ['eslint:recommended', 'preact', 'prettier'],
|
||||
plugins: ['react', 'jest'],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
mocha: true,
|
||||
es6: true,
|
||||
'jest/globals': true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
modules: true,
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
pragma: 'h',
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
sleep: true,
|
||||
},
|
||||
rules: {
|
||||
indent: ['error', 2, { SwitchCase: 1 }],
|
||||
'comma-dangle': ['error', { objects: 'always-multiline', arrays: 'always-multiline' }],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
},
|
||||
],
|
||||
};
|
24
web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
4
web/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"singleQuote": true
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
# Frigate Web UI
|
||||
|
||||
For installation and contributing instructions, please follow the [Contributing Docs](https://blakeblackshear.github.io/frigate/contributing).
|
73
web/config/handlers.js
Normal file
@ -0,0 +1,73 @@
|
||||
import { rest } from 'msw';
|
||||
import { API_HOST } from '../src/env';
|
||||
|
||||
export const handlers = [
|
||||
rest.get(`${API_HOST}/api/config`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
mqtt: {
|
||||
stats_interval: 60,
|
||||
},
|
||||
service: {
|
||||
version: '0.8.3',
|
||||
},
|
||||
cameras: {
|
||||
front: {
|
||||
name: 'front',
|
||||
objects: { track: ['taco', 'cat', 'dog'] },
|
||||
record: { enabled: true },
|
||||
detect: { width: 1280, height: 720 },
|
||||
snapshots: {},
|
||||
live: { height: 720 },
|
||||
},
|
||||
side: {
|
||||
name: 'side',
|
||||
objects: { track: ['taco', 'cat', 'dog'] },
|
||||
record: { enabled: false },
|
||||
detect: { width: 1280, height: 720 },
|
||||
snapshots: {},
|
||||
live: { height: 720 },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get(`${API_HOST}/api/stats`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
detection_fps: 0.0,
|
||||
detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
|
||||
front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
|
||||
side: {
|
||||
camera_fps: 6.9,
|
||||
capture_pid: 71,
|
||||
detection_fps: 0.0,
|
||||
pid: 60,
|
||||
process_fps: 0.0,
|
||||
skipped_fps: 0.0,
|
||||
},
|
||||
service: { uptime: 34812, version: '0.8.1-d376f6b' },
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get(`${API_HOST}/api/events`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
new Array(12).fill(null).map((v, i) => ({
|
||||
end_time: 1613257337 + i,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
id: i,
|
||||
label: 'person',
|
||||
start_time: 1613257326 + i,
|
||||
top_score: Math.random(),
|
||||
zones: ['front_patio'],
|
||||
thumbnail: '/9j/4aa...',
|
||||
}))
|
||||
)
|
||||
);
|
||||
}),
|
||||
];
|
6
web/config/server.js
Normal file
@ -0,0 +1,6 @@
|
||||
// src/mocks/server.js
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
// This configures a request mocking server with the given request handlers.
|
||||
export const server = setupServer(...handlers);
|
@ -1,5 +1,6 @@
|
||||
import 'regenerator-runtime/runtime';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { server } from './server.js';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@ -13,6 +14,16 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
}),
|
||||
});
|
||||
|
||||
window.fetch = () => Promise.resolve();
|
||||
|
||||
jest.mock('../src/env');
|
||||
|
||||
// Establish API mocking before all tests.
|
||||
beforeAll(() => server.listen());
|
||||
|
||||
// Reset any request handlers that we may add during the tests,
|
||||
// so they don't affect other tests.
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Clean up after the tests are finished.
|
||||
afterAll(() => server.close());
|
||||
|
24
web/config/testing-library.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { h } from 'preact';
|
||||
import { render } from '@testing-library/preact';
|
||||
import { ApiProvider } from '../src/api';
|
||||
|
||||
const Wrapper = ({ children }) => {
|
||||
return (
|
||||
<ApiProvider
|
||||
options={{
|
||||
dedupingInterval: 0,
|
||||
provider: () => new Map(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ApiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const customRender = (ui, options) => render(ui, { wrapper: Wrapper, ...options });
|
||||
|
||||
// re-export everything
|
||||
export * from '@testing-library/preact';
|
||||
|
||||
// override render method
|
||||
export { customRender as render };
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 558 B |
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 800 B |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 534 B |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
@ -1,25 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/images/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frigate</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#3b82f7" />
|
||||
<link rel="mask-icon" href="/images/safari-pinned-tab.svg" color="#3b82f7" />
|
||||
<meta name="msapplication-TileColor" content="#3b82f7" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#111827" media="(prefers-color-scheme: dark)" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="z-0"></div>
|
||||
<div id="app" class="z-0"></div>
|
||||
<div id="dialogs" class="z-0"></div>
|
||||
<div id="menus" class="z-0"></div>
|
||||
<div id="tooltips" class="z-0"></div>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<script type="module" src="/dist/index.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,12 +1,198 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'jsx'],
|
||||
name: 'react-component-benchmark',
|
||||
resetMocks: true,
|
||||
roots: ['<rootDir>'],
|
||||
setupFilesAfterEnv: ['<rootDir>/config/setupTests.js'],
|
||||
testEnvironment: 'jsdom',
|
||||
timers: 'fake',
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls, instances and results before every test
|
||||
// clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
// coverageProvider: "babel",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
'\\.(scss|sass|css)$': '<rootDir>/src/__mocks__/styleMock.js',
|
||||
'^testing-library$': '<rootDir>/config/testing-library',
|
||||
'^react$': 'preact/compat',
|
||||
'^react-dom$': 'preact/compat',
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
resetMocks: true,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: ['<rootDir>'],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
setupFilesAfterEnv: ['<rootDir>/config/setupTests.js'],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: 'fake',
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment"
|
||||
},
|
||||
"include": ["./src/**/*.js", "./src/**/*.jsx"]
|
||||
}
|
36177
web/package-lock.json
generated
@ -1,58 +1,50 @@
|
||||
{
|
||||
"name": "frigate",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||
"prebuild": "rimraf build",
|
||||
"build": "SNOWPACK_PUBLIC_API_HOST='' NODE_ENV=production SNOWPACK_MODE=production snowpack build",
|
||||
"lint": "npm run lint:cmd -- --fix",
|
||||
"lint:cmd": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
||||
"dev": "vite",
|
||||
"lint": "eslint ./ --ext .jsx,.js,.tsx,.ts",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^5.0.1",
|
||||
"axios": "^0.26.0",
|
||||
"date-fns": "^2.21.3",
|
||||
"idb-keyval": "^5.0.2",
|
||||
"immer": "^9.0.6",
|
||||
"date-fns": "^2.28.0",
|
||||
"idb-keyval": "^6.1.0",
|
||||
"immer": "^9.0.12",
|
||||
"preact": "^10.6.6",
|
||||
"preact-async-route": "^2.2.1",
|
||||
"preact-router": "^4.0.1",
|
||||
"swr": "^1.2.2",
|
||||
"video.js": "^7.15.4",
|
||||
"videojs-playlist": "^4.3.1",
|
||||
"videojs-seek-buttons": "^2.0.1"
|
||||
"video.js": "^7.17.0",
|
||||
"videojs-playlist": "^5.0.0",
|
||||
"videojs-seek-buttons": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.17.0",
|
||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||
"@babel/preset-env": "^7.12.13",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@prefresh/snowpack": "^3.1.4",
|
||||
"@snowpack/plugin-postcss": "^1.1.0",
|
||||
"@snowpack/plugin-typescript": "^1.2.1",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@testing-library/jest-dom": "^5.11.9",
|
||||
"@preact/preset-vite": "^2.1.5",
|
||||
"@tailwindcss/forms": "^0.5.0",
|
||||
"@testing-library/jest-dom": "^5.16.2",
|
||||
"@testing-library/preact": "^2.0.1",
|
||||
"@testing-library/user-event": "^12.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.12.0",
|
||||
"@typescript-eslint/parser": "^5.12.0",
|
||||
"@testing-library/preact-hooks": "^1.1.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^26.1.1",
|
||||
"eslint-plugin-testing-library": "^5.0.5",
|
||||
"jest": "^26.6.3",
|
||||
"jest": "^27.5.1",
|
||||
"msw": "^0.38.2",
|
||||
"postcss": "^8.4.7",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"preact-cli": "^3.3.5",
|
||||
"prettier": "^2.2.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"snowpack": "^3.8.8",
|
||||
"snowpack-plugin-hash": "^0.16.0",
|
||||
"tailwindcss": "^3.0.23"
|
||||
"prettier": "^2.5.1",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.8.0"
|
||||
}
|
||||
}
|
||||
|
@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
singleQuote: true,
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
19
web/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/images/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
module.exports = {
|
||||
mount: {
|
||||
public: { url: '/', static: true },
|
||||
src: { url: '/dist' },
|
||||
},
|
||||
plugins: ['@prefresh/snowpack', '@snowpack/plugin-typescript', '@snowpack/plugin-postcss', 'snowpack-plugin-hash'],
|
||||
devOptions: {
|
||||
tailwindConfig: './tailwind.config.js',
|
||||
},
|
||||
routes: [{ match: 'all', src: '(?!.*(.svg|.gif|.json|.jpg|.jpeg|.png|.js)).*', dest: '/index.html' }],
|
||||
optimize: {
|
||||
bundle: false,
|
||||
minify: true,
|
||||
treeshake: true,
|
||||
},
|
||||
packageOptions: {
|
||||
knownEntrypoints: [
|
||||
'@videojs/vhs-utils/es/stream.js',
|
||||
'@videojs/vhs-utils/es/resolve-url.js',
|
||||
'@videojs/vhs-utils/es/media-types.js',
|
||||
'@videojs/vhs-utils/es/decode-b64-to-uint8-array.js',
|
||||
'@videojs/vhs-utils/es/id3-helpers',
|
||||
'@videojs/vhs-utils/es/byte-helpers',
|
||||
'@videojs/vhs-utils/es/containers',
|
||||
'@videojs/vhs-utils/es/codecs.js',
|
||||
'global/window',
|
||||
'global/document',
|
||||
],
|
||||
},
|
||||
buildOptions: {},
|
||||
alias: {
|
||||
react: 'preact/compat',
|
||||
'react-dom': 'preact/compat',
|
||||
},
|
||||
};
|
@ -4,13 +4,14 @@ import { Match } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { ENV } from './env';
|
||||
import useSWR from 'swr';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { data: config } = useSWR('config');
|
||||
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
|
||||
const { birdseye } = config;
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const { cameras, birdseye } = config;
|
||||
|
||||
return (
|
||||
<NavigationDrawer header={<Header />}>
|
||||
@ -20,7 +21,7 @@ export default function Sidebar() {
|
||||
matches ? (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
{cameras.map(([camera]) => (
|
||||
{Object.entries(cameras).map(([camera]) => (
|
||||
<Destination key={camera} href={`/cameras/${camera}`} text={camera} />
|
||||
))}
|
||||
<Separator />
|
||||
@ -33,7 +34,7 @@ export default function Sidebar() {
|
||||
matches ? (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
{cameras.map(([camera, conf]) => {
|
||||
{Object.entries(cameras).map(([camera, conf]) => {
|
||||
if (conf.record.enabled) {
|
||||
return (
|
||||
<Destination
|
||||
|
@ -1,25 +1,17 @@
|
||||
import { h } from 'preact';
|
||||
import * as Api from '../api';
|
||||
import * as IDB from 'idb-keyval';
|
||||
import * as PreactRouter from 'preact-router';
|
||||
import App from '../App';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('App', () => {
|
||||
let mockUseConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined));
|
||||
jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true));
|
||||
mockUseConfig = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: { cameras: { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } } } },
|
||||
}));
|
||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
|
||||
jest.spyOn(PreactRouter, 'Router').mockImplementation(() => <div data-testid="router" />);
|
||||
});
|
||||
|
||||
test('shows a loading indicator while loading', async () => {
|
||||
mockUseConfig.mockReturnValue({ status: 'loading' });
|
||||
render(<App />);
|
||||
await screen.findByTestId('app');
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import * as Context from '../context';
|
||||
import AppBar from '../AppBar';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
describe('AppBar', () => {
|
||||
beforeEach(() => {
|
||||
|
@ -1,40 +1,17 @@
|
||||
import { h } from 'preact';
|
||||
import * as Api from '../api';
|
||||
import * as Context from '../context';
|
||||
import Sidebar from '../Sidebar';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: {
|
||||
cameras: {
|
||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true } },
|
||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer: () => {} }));
|
||||
});
|
||||
|
||||
test('does not render cameras by default', async () => {
|
||||
render(<Sidebar />);
|
||||
const { findByText } = render(<Sidebar />);
|
||||
await findByText('Cameras');
|
||||
expect(screen.queryByRole('link', { name: 'front' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render cameras if in camera route', async () => {
|
||||
window.history.replaceState({}, 'Cameras', '/cameras/front');
|
||||
render(<Sidebar />);
|
||||
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'side' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render cameras if in record route', async () => {
|
||||
window.history.replaceState({}, 'Front Recordings', '/recording/front');
|
||||
render(<Sidebar />);
|
||||
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import * as Mqtt from '../mqtt';
|
||||
import { ApiProvider, useFetch, useApiHost } from '..';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { ApiProvider, useApiHost } from '..';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('useApiHost', () => {
|
||||
beforeEach(() => {
|
||||
@ -21,101 +21,3 @@ describe('useApiHost', () => {
|
||||
expect(screen.queryByText('http://base-url.local:5000')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function Test() {
|
||||
const { data, status } = useFetch('/api/tacos');
|
||||
return (
|
||||
<div>
|
||||
<span>{data ? data.returnData : ''}</span>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFetch', () => {
|
||||
let fetchSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
||||
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url) => {
|
||||
if (url.endsWith('/api/config')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
|
||||
}, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('loads data', async () => {
|
||||
render(
|
||||
<ApiProvider>
|
||||
<Test />
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText('yep')).not.toBeInTheDocument();
|
||||
|
||||
jest.runAllTimers();
|
||||
await screen.findByText('loaded');
|
||||
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
|
||||
|
||||
expect(screen.queryByText('loaded')).toBeInTheDocument();
|
||||
expect(screen.queryByText('yep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sets error if response is not okay', async () => {
|
||||
jest.spyOn(window, 'fetch').mockImplementation((url) => {
|
||||
if (url.includes('/config')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ ok: false });
|
||||
}, 1);
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<ApiProvider>
|
||||
<Test />
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('loading')).toBeInTheDocument();
|
||||
jest.runAllTimers();
|
||||
await screen.findByText('error');
|
||||
});
|
||||
|
||||
test('does not re-fetch if the query has already been made', async () => {
|
||||
const { rerender } = render(
|
||||
<ApiProvider>
|
||||
<Test key={0} />
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText('yep')).not.toBeInTheDocument();
|
||||
|
||||
jest.runAllTimers();
|
||||
await screen.findByText('loaded');
|
||||
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
|
||||
|
||||
rerender(
|
||||
<ApiProvider>
|
||||
<Test key={1} />
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('loaded')).toBeInTheDocument();
|
||||
expect(screen.queryByText('yep')).toBeInTheDocument();
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
// once for /api/config, once for /api/tacos
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { Mqtt, MqttProvider, useMqtt } from '../mqtt';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
function Test() {
|
||||
const { state } = useContext(Mqtt);
|
||||
|
@ -6,11 +6,12 @@ import axios from 'axios';
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}/api/`;
|
||||
|
||||
export function ApiProvider({ children }) {
|
||||
export function ApiProvider({ children, options }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (path) => axios.get(path).then((res) => res.data),
|
||||
...options,
|
||||
}}
|
||||
>
|
||||
<MqttWithConfig>{children}</MqttWithConfig>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { h, JSX } from 'preact';
|
||||
|
||||
interface BubbleButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
children?: preact.JSX.Element;
|
||||
children?: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
|
@ -98,7 +98,11 @@ const Calendar = ({ onChange, calendarRef, close }) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedDay: todayTimestamp,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
}));
|
||||
}, [year, month, getMonthDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -150,7 +154,10 @@ const Calendar = ({ onChange, calendarRef, close }) => {
|
||||
|
||||
// user has selected a date < after, reset values
|
||||
if (after === null || day.timestamp < after) {
|
||||
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
|
||||
timeRange = {
|
||||
before: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
after: day.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// user has selected a date > after
|
||||
@ -159,8 +166,8 @@ const Calendar = ({ onChange, calendarRef, close }) => {
|
||||
after,
|
||||
before:
|
||||
day.timestamp >= todayTimestamp
|
||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -243,26 +250,26 @@ const Calendar = ({ onChange, calendarRef, close }) => {
|
||||
const days =
|
||||
state.monthDetails &&
|
||||
state.monthDetails.map((day, idx) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||
}
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||
}
|
||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
@ -314,7 +321,7 @@ const Calendar = ({ onChange, calendarRef, close }) => {
|
||||
<ArrowRight className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
|
||||
<div className="w-1/6 relative flex justify-around" tabIndex={104} onClick={() => setYear(1)}>
|
||||
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
|
||||
<ArrowRightDouble className="h-2/6" />
|
||||
</div>
|
||||
|
@ -13,8 +13,8 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
|
||||
const canvasRef = useRef(null);
|
||||
const [{ width: availableWidth }] = useResizeObserver(containerRef);
|
||||
|
||||
const { name } = config.cameras[camera];
|
||||
const { width, height } = config.cameras[camera].detect;
|
||||
const { name } = config ? config.cameras[camera] : '';
|
||||
const { width, height } = config ? config.cameras[camera].detect : { width: 1, height: 1 };
|
||||
const aspectRatio = width / height;
|
||||
|
||||
const scaledHeight = useMemo(() => {
|
||||
@ -37,11 +37,11 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scaledHeight === 0 || !canvasRef.current) {
|
||||
if (!config || scaledHeight === 0 || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
|
||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight]);
|
||||
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
|
@ -3,7 +3,7 @@ import Heading from '../Heading';
|
||||
import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
|
||||
interface HistoryHeaderProps {
|
||||
event: TimelineEvent;
|
||||
event?: TimelineEvent;
|
||||
className?: string;
|
||||
}
|
||||
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {
|
||||
|
@ -15,7 +15,7 @@ interface VideoProperties {
|
||||
}
|
||||
|
||||
interface HistoryVideoProps {
|
||||
id: string;
|
||||
id?: string;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
onTimeUpdate?: (event: OnTimeUpdateEvent) => void;
|
||||
@ -32,9 +32,13 @@ export const HistoryVideo = ({
|
||||
onPlay,
|
||||
}: HistoryVideoProps) => {
|
||||
const apiHost = useApiHost();
|
||||
const videoRef = useRef<HTMLVideoElement>();
|
||||
const [videoHeight, setVideoHeight] = useState<number>(undefined);
|
||||
const [videoProperties, setVideoProperties] = useState<VideoProperties>(undefined);
|
||||
const videoRef = useRef<HTMLVideoElement|null>(null);
|
||||
const [videoHeight, setVideoHeight] = useState<number>(0);
|
||||
const [videoProperties, setVideoProperties] = useState<VideoProperties>({
|
||||
posterUrl: '',
|
||||
videoUrl: '',
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const currentVideo = videoRef.current;
|
||||
if (currentVideo && !videoHeight) {
|
||||
@ -48,7 +52,7 @@ export const HistoryVideo = ({
|
||||
const idExists = !isNullOrUndefined(id);
|
||||
if (idExists) {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current = undefined;
|
||||
videoRef.current = null;
|
||||
}
|
||||
|
||||
setVideoProperties({
|
||||
@ -57,7 +61,11 @@ export const HistoryVideo = ({
|
||||
height: videoHeight,
|
||||
});
|
||||
} else {
|
||||
setVideoProperties(undefined);
|
||||
setVideoProperties({
|
||||
posterUrl: '',
|
||||
videoUrl: '',
|
||||
height: 0,
|
||||
});
|
||||
}
|
||||
}, [id, videoHeight, videoRef, apiHost]);
|
||||
|
||||
@ -78,7 +86,7 @@ export const HistoryVideo = ({
|
||||
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
if (videoExists) {
|
||||
if (video && videoExists) {
|
||||
if (videoIsPlaying) {
|
||||
attemptPlayVideo(video);
|
||||
} else {
|
||||
@ -91,7 +99,7 @@ export const HistoryVideo = ({
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
const hasSeeked = currentTime >= 0;
|
||||
if (videoExists && hasSeeked) {
|
||||
if (video && videoExists && hasSeeked) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime, videoRef]);
|
||||
|
@ -8,7 +8,7 @@ import type { TimelineEvent } from '../Timeline/TimelineEvent';
|
||||
import { HistoryHeader } from './HistoryHeader';
|
||||
import { HistoryVideo } from './HistoryVideo';
|
||||
|
||||
export default function HistoryViewer({ camera }) {
|
||||
export default function HistoryViewer({ camera }: {camera: string}) {
|
||||
const searchParams = {
|
||||
before: null,
|
||||
after: null,
|
||||
@ -18,17 +18,17 @@ export default function HistoryViewer({ camera }) {
|
||||
};
|
||||
|
||||
// TODO: refactor
|
||||
const eventsFetcher = (path, params) => {
|
||||
const eventsFetcher = (path: string, params: {[name:string]: string|number}) => {
|
||||
params = { ...params, include_thumbnails: 0, limit: 500 };
|
||||
return axios.get(path, { params }).then((res) => res.data);
|
||||
return axios.get<TimelineEvent[]>(path, { params }).then((res) => res.data);
|
||||
};
|
||||
|
||||
const { data: events } = useSWR(['events', searchParams], eventsFetcher);
|
||||
|
||||
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
|
||||
const [isPlaying, setIsPlaying] = useState(undefined);
|
||||
const [currentTime, setCurrentTime] = useState<number>(undefined);
|
||||
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) {
|
||||
|
@ -16,7 +16,7 @@ interface TimelineProps {
|
||||
}
|
||||
|
||||
export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) {
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(undefined);
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
|
||||
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
|
||||
@ -24,10 +24,10 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
|
||||
next: true,
|
||||
previous: false,
|
||||
});
|
||||
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
|
||||
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
|
||||
const [timelineOffset, setTimelineOffset] = useState<number>(0);
|
||||
const [markerTime, setMarkerTime] = useState<Date>(new Date());
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout>();
|
||||
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
@ -51,7 +51,7 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
|
||||
);
|
||||
|
||||
const scrollToEvent = useCallback(
|
||||
(event, offset = 0) => {
|
||||
(event: TimelineEventBlock, offset = 0) => {
|
||||
scrollToPosition(event.positionX + offset - timelineOffset);
|
||||
},
|
||||
[timelineOffset, scrollToPosition]
|
||||
@ -137,7 +137,9 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
|
||||
};
|
||||
|
||||
const waitForSeekComplete = (markerTime: Date) => {
|
||||
clearTimeout(scrollTimeout);
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150));
|
||||
};
|
||||
|
||||
@ -161,11 +163,12 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
|
||||
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
|
||||
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
|
||||
}
|
||||
return new Date();
|
||||
}, [timeline, timelineContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
|
||||
const timelineContainerWidth = timelineContainerRef.current?.offsetWidth || 0;
|
||||
const offset = Math.round(timelineContainerWidth / 2);
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
@ -180,7 +183,7 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
|
||||
);
|
||||
|
||||
const onPlayPauseHandler = (isPlaying: boolean) => {
|
||||
onPlayPause(isPlaying);
|
||||
onPlayPause && onPlayPause(isPlaying);
|
||||
};
|
||||
|
||||
const onPreviousHandler = () => {
|
||||
|
@ -41,6 +41,7 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div />
|
||||
}, [timeline, onEventClick, firstBlockOffset]);
|
||||
|
||||
return timelineEventBlocks;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent: TimelineEvent;
|
||||
timelineEvent?: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../ActivityIndicator';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('ActivityIndicator', () => {
|
||||
test('renders an ActivityIndicator with default size md', async () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { DrawerProvider } from '../../context';
|
||||
import AppBar from '../AppBar';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
function Title() {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import AutoUpdatingCameraImage from '../AutoUpdatingCameraImage';
|
||||
import { screen, render } from '@testing-library/preact';
|
||||
import { screen, render } from 'testing-library';
|
||||
|
||||
let mockOnload;
|
||||
jest.mock('../CameraImage', () => {
|
||||
@ -34,9 +34,9 @@ describe('AutoUpdatingCameraImage', () => {
|
||||
|
||||
test('on load, sets a new cache key to search params', async () => {
|
||||
dateNowSpy.mockReturnValueOnce(100).mockReturnValueOnce(200).mockReturnValueOnce(300);
|
||||
render(<AutoUpdatingCameraImage camera="tacos" searchParams="foo" />);
|
||||
render(<AutoUpdatingCameraImage camera="front" searchParams="foo" />);
|
||||
mockOnload();
|
||||
jest.runAllTimers();
|
||||
expect(screen.queryByText('cache=100&foo')).toBeInTheDocument();
|
||||
await screen.findByText('cache=100&foo');
|
||||
expect(screen.getByText('cache=100&foo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Button from '../Button';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Button', () => {
|
||||
test('renders children', async () => {
|
||||
|
@ -1,15 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import * as Api from '../../api';
|
||||
import * as Hooks from '../../hooks';
|
||||
import CameraImage from '../CameraImage';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('CameraImage', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => {
|
||||
return { data: { cameras: { front: { name: 'front', detect: { width: 1280, height: 720 } } } } };
|
||||
});
|
||||
jest.spyOn(Api, 'useApiHost').mockReturnValue('http://base-url.local:5000');
|
||||
jest.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
|
||||
});
|
||||
|
||||
@ -17,24 +12,4 @@ describe('CameraImage', () => {
|
||||
render(<CameraImage camera="front" />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('creates a scaled canvas using the available width & height, preserving camera aspect ratio', async () => {
|
||||
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 720 }]);
|
||||
|
||||
render(<CameraImage camera="front" />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
const canvas = screen.queryByTestId('cameraimage-canvas');
|
||||
expect(canvas).toHaveAttribute('height', '405');
|
||||
expect(canvas).toHaveAttribute('width', '720');
|
||||
});
|
||||
|
||||
test('allows camera image to stretch to available space', async () => {
|
||||
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 1400 }]);
|
||||
|
||||
render(<CameraImage camera="front" stretch />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
const canvas = screen.queryByTestId('cameraimage-canvas');
|
||||
expect(canvas).toHaveAttribute('height', '787');
|
||||
expect(canvas).toHaveAttribute('width', '1400');
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Card from '../Card';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Card', () => {
|
||||
test('renders a Card with media', async () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Dialog from '../Dialog';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Dialog', () => {
|
||||
let portal;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Heading', () => {
|
||||
test('renders content with default size', async () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Link from '../Link';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Link', () => {
|
||||
test('renders a link', async () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Menu, { MenuItem } from '../Menu';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
describe('Menu', () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import * as Context from '../../context';
|
||||
import NavigationDrawer, { Destination } from '../NavigationDrawer';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
describe('NavigationDrawer', () => {
|
||||
let useDrawer, setShowDrawer;
|
||||
@ -49,6 +49,7 @@ describe('Destination', () => {
|
||||
});
|
||||
|
||||
test('dismisses the drawer moments after being clicked', async () => {
|
||||
jest.useFakeTimers();
|
||||
render(
|
||||
<NavigationDrawer>
|
||||
<Destination href="/tacos" text="Tacos" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Prompt from '../Prompt';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
describe('Prompt', () => {
|
||||
let portal;
|
||||
@ -16,7 +16,7 @@ describe('Prompt', () => {
|
||||
});
|
||||
|
||||
test('renders to a portal', async () => {
|
||||
render(<Prompt title='Tacos' text='This is the dialog' />);
|
||||
render(<Prompt title="Tacos" text="This is the dialog" />);
|
||||
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||
});
|
||||
@ -29,7 +29,7 @@ describe('Prompt', () => {
|
||||
{ color: 'red', text: 'Delete' },
|
||||
{ text: 'Okay', onClick: handleClick },
|
||||
]}
|
||||
title='Tacos'
|
||||
title="Tacos"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { h, createRef } from 'preact';
|
||||
import RelativeModal from '../RelativeModal';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
describe('RelativeModal', () => {
|
||||
test('keeps tab focus', async () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Select from '../Select';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
describe('Select', () => {
|
||||
test('on focus, shows a menu', async () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import Switch from '../Switch';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
|
||||
describe('Switch', () => {
|
||||
test('renders a hidden checkbox', async () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import TextField from '../TextField';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { render, screen, fireEvent } from 'testing-library';
|
||||
|
||||
describe('TextField', () => {
|
||||
test('can render a leading icon', async () => {
|
||||
@ -20,20 +20,6 @@ describe('TextField', () => {
|
||||
expect(icons[1]).toHaveAttribute('data-testid', 'icon-trailing');
|
||||
});
|
||||
|
||||
test('focuses and blurs', async () => {
|
||||
const handleFocus = jest.fn();
|
||||
const handleBlur = jest.fn();
|
||||
render(<TextField label="Tacos" onFocus={handleFocus} onBlur={handleBlur} />);
|
||||
|
||||
fireEvent.focus(screen.getByRole('textbox'));
|
||||
expect(handleFocus).toHaveBeenCalled();
|
||||
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(true);
|
||||
|
||||
fireEvent.blur(screen.getByRole('textbox'));
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(false);
|
||||
});
|
||||
|
||||
test('onChange updates the value', async () => {
|
||||
const handleChangeText = jest.fn();
|
||||
render(<TextField label="Tacos" onChangeText={handleChangeText} />);
|
||||
|
@ -1,19 +1,19 @@
|
||||
import { h, createRef } from 'preact';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen } from 'testing-library';
|
||||
|
||||
describe('Tooltip', () => {
|
||||
test('renders in a relative position', async () => {
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 40, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
@ -34,14 +34,14 @@ describe('Tooltip', () => {
|
||||
window.innerWidth = 1024;
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 1000,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
@ -61,14 +61,14 @@ describe('Tooltip', () => {
|
||||
test('if too far left, renders to the right', async () => {
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
@ -89,14 +89,14 @@ describe('Tooltip', () => {
|
||||
window.scrollY = 90;
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { h } from 'preact';
|
||||
import * as IDB from 'idb-keyval';
|
||||
import { DarkModeProvider, useDarkMode, usePersistence } from '..';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import * as Mqtt from '../../api/mqtt';
|
||||
|
||||
function DarkModeChecker() {
|
||||
const { currentMode } = useDarkMode();
|
||||
@ -16,6 +17,8 @@ describe('DarkMode', () => {
|
||||
get: jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined)),
|
||||
set: jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true)),
|
||||
};
|
||||
|
||||
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
||||
});
|
||||
|
||||
test('uses media by default', async () => {
|
||||
@ -148,8 +151,6 @@ describe('usePersistence', () => {
|
||||
my-default
|
||||
</div>
|
||||
`);
|
||||
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
test('updates with the previously-persisted value', async () => {
|
||||
|
@ -1,2 +1,2 @@
|
||||
export const ENV = import.meta.env.MODE;
|
||||
export const API_HOST = import.meta.env.SNOWPACK_PUBLIC_API_HOST;
|
||||
export const API_HOST = ENV === "production" ? "" : "http://localhost:5000";
|
||||
|
@ -1,6 +1,6 @@
|
||||
import App from './App';
|
||||
import { ApiProvider } from './api';
|
||||
import { h, render } from 'preact';
|
||||
import { render } from 'preact';
|
||||
import 'preact/devtools';
|
||||
import './index.css';
|
||||
|
||||
@ -8,5 +8,5 @@ render(
|
||||
<ApiProvider>
|
||||
<App />
|
||||
</ApiProvider>,
|
||||
document.getElementById('root')
|
||||
document.getElementById('app') as HTMLElement
|
||||
);
|
5
web/src/preact.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import JSX = preact.JSX
|
||||
|
||||
// temporary until codebase is converted to typescript
|
||||
declare module "*";
|
@ -1,5 +1,6 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import AutoUpdatingCameraImage from '../components/AutoUpdatingCameraImage';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import JSMpegPlayer from '../components/JSMpegPlayer';
|
||||
import Button from '../components/Button';
|
||||
import Card from '../components/Card';
|
||||
@ -22,7 +23,9 @@ export default function Camera({ camera }) {
|
||||
const [viewMode, setViewMode] = useState('live');
|
||||
|
||||
const cameraConfig = config?.cameras[camera];
|
||||
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
|
||||
const liveWidth = cameraConfig
|
||||
? Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
|
||||
: 0;
|
||||
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
@ -48,6 +51,10 @@ export default function Camera({ camera }) {
|
||||
setShowSettings(!showSettings);
|
||||
}, [showSettings, setShowSettings]);
|
||||
|
||||
if (!cameraConfig) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
const optionContent = showSettings ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<Switch
|
||||
@ -93,8 +100,7 @@ export default function Camera({ camera }) {
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
else if (viewMode === 'debug') {
|
||||
} else if (viewMode === 'debug') {
|
||||
player = (
|
||||
<Fragment>
|
||||
<div>
|
||||
|
@ -67,13 +67,13 @@ export default function Events({ path, ...props }) {
|
||||
|
||||
const { data: config } = useSWR('config');
|
||||
|
||||
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
|
||||
const cameras = useMemo(() => Object.keys(config?.cameras || {}), [config]);
|
||||
|
||||
const zones = useMemo(
|
||||
() =>
|
||||
Object.values(config.cameras)
|
||||
Object.values(config?.cameras || {})
|
||||
.reduce((memo, camera) => {
|
||||
memo = memo.concat(Object.keys(camera.zones));
|
||||
memo = memo.concat(Object.keys(camera?.zones || {}));
|
||||
return memo;
|
||||
}, [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i),
|
||||
@ -81,11 +81,11 @@ export default function Events({ path, ...props }) {
|
||||
);
|
||||
|
||||
const labels = useMemo(() => {
|
||||
return Object.values(config.cameras)
|
||||
return Object.values(config?.cameras || {})
|
||||
.reduce((memo, camera) => {
|
||||
memo = memo.concat(camera.objects?.track || []);
|
||||
memo = memo.concat(camera?.objects?.track || []);
|
||||
return memo;
|
||||
}, config.objects?.track || [])
|
||||
}, config?.objects?.track || [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i);
|
||||
}, [config]);
|
||||
|
||||
@ -123,6 +123,7 @@ export default function Events({ path, ...props }) {
|
||||
|
||||
const handleSelectDateRange = useCallback(
|
||||
(dates) => {
|
||||
console.log(dates);
|
||||
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
|
||||
setShowDatePicker(false);
|
||||
},
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import * as AutoUpdatingCameraImage from '../../components/AutoUpdatingCameraImage';
|
||||
import * as Api from '../../api';
|
||||
import * as Context from '../../context';
|
||||
import * as Mqtt from '../../api/mqtt';
|
||||
import Camera from '../Camera';
|
||||
import * as JSMpegPlayer from '../../components/JSMpegPlayer';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
|
||||
|
||||
describe('Camera Route', () => {
|
||||
let mockUsePersistence, mockSetOptions;
|
||||
@ -12,16 +12,13 @@ describe('Camera Route', () => {
|
||||
beforeEach(() => {
|
||||
mockSetOptions = jest.fn();
|
||||
mockUsePersistence = jest.spyOn(Context, 'usePersistence').mockImplementation(() => [{}, mockSetOptions]);
|
||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: { cameras: { front: { name: 'front', detect: {width: 1280, height: 720}, live: {height: 720}, objects: { track: ['taco', 'cat', 'dog'] } } } },
|
||||
}));
|
||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
|
||||
jest.spyOn(AutoUpdatingCameraImage, 'default').mockImplementation(({ searchParams }) => {
|
||||
return <div data-testid="mock-image">{searchParams.toString()}</div>;
|
||||
});
|
||||
jest.spyOn(JSMpegPlayer, 'default').mockImplementation(() => {
|
||||
return <div data-testid="mock-jsmpeg" />;
|
||||
});
|
||||
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
||||
});
|
||||
|
||||
test('reads camera feed options from persistence', async () => {
|
||||
@ -39,6 +36,8 @@ describe('Camera Route', () => {
|
||||
|
||||
render(<Camera camera="front" />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
fireEvent.click(screen.queryByText('Debug'));
|
||||
fireEvent.click(screen.queryByText('Show Options'));
|
||||
expect(screen.queryByTestId('mock-image')).toHaveTextContent(
|
||||
@ -48,6 +47,7 @@ describe('Camera Route', () => {
|
||||
|
||||
test('updates camera feed options to persistence', async () => {
|
||||
mockUsePersistence
|
||||
.mockReturnValueOnce([{}, mockSetOptions])
|
||||
.mockReturnValueOnce([{}, mockSetOptions])
|
||||
.mockReturnValueOnce([{}, mockSetOptions])
|
||||
.mockReturnValueOnce([{ bbox: true }, mockSetOptions])
|
||||
@ -55,13 +55,15 @@ describe('Camera Route', () => {
|
||||
|
||||
render(<Camera camera="front" />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
fireEvent.click(screen.queryByText('Debug'));
|
||||
fireEvent.click(screen.queryByText('Show Options'));
|
||||
fireEvent.change(screen.queryByTestId('bbox-input'), { target: { checked: true } });
|
||||
fireEvent.change(screen.queryByTestId('timestamp-input'), { target: { checked: true } });
|
||||
fireEvent.click(screen.queryByText('Hide Options'));
|
||||
|
||||
expect(mockUsePersistence).toHaveBeenCalledTimes(4);
|
||||
expect(mockUsePersistence).toHaveBeenCalledTimes(5);
|
||||
expect(mockSetOptions).toHaveBeenCalledTimes(2);
|
||||
expect(mockSetOptions).toHaveBeenCalledWith({ bbox: true, timestamp: true });
|
||||
expect(screen.queryByTestId('mock-image')).toHaveTextContent('bbox=1×tamp=1');
|
||||
|
@ -1,30 +1,16 @@
|
||||
import { h } from 'preact';
|
||||
import * as Api from '../../api';
|
||||
import * as CameraImage from '../../components/CameraImage';
|
||||
import * as Mqtt from '../../api/mqtt';
|
||||
import Cameras from '../Cameras';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
|
||||
|
||||
describe('Cameras Route', () => {
|
||||
let useConfigMock;
|
||||
|
||||
beforeEach(() => {
|
||||
useConfigMock = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: {
|
||||
cameras: {
|
||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true } },
|
||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false } },
|
||||
},
|
||||
},
|
||||
status: 'loaded',
|
||||
}));
|
||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
|
||||
jest.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
||||
jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
|
||||
});
|
||||
|
||||
test('shows an ActivityIndicator if not yet loaded', async () => {
|
||||
useConfigMock.mockReturnValueOnce(() => ({ status: 'loading' }));
|
||||
render(<Cameras />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
@ -32,7 +18,7 @@ describe('Cameras Route', () => {
|
||||
test('shows cameras', async () => {
|
||||
render(<Cameras />);
|
||||
|
||||
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
expect(screen.queryByText('front')).toBeInTheDocument();
|
||||
expect(screen.queryByText('front').closest('a')).toHaveAttribute('href', '/cameras/front');
|
||||
@ -44,7 +30,7 @@ describe('Cameras Route', () => {
|
||||
test('shows recordings link', async () => {
|
||||
render(<Cameras />);
|
||||
|
||||
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
expect(screen.queryAllByText('Recordings')).toHaveLength(2);
|
||||
});
|
||||
@ -65,6 +51,8 @@ describe('Cameras Route', () => {
|
||||
|
||||
render(<Cameras />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Toggle detect off')[0]);
|
||||
expect(sendDetect).toHaveBeenCalledWith('OFF');
|
||||
expect(sendDetect).toHaveBeenCalledTimes(1);
|
||||
|
@ -1,41 +1,19 @@
|
||||
import { h } from 'preact';
|
||||
import * as Api from '../../api';
|
||||
import * as Mqtt from '../../api/mqtt';
|
||||
import Debug from '../Debug';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
|
||||
|
||||
describe('Debug Route', () => {
|
||||
let useStatsMock, useMqttMock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: {
|
||||
service: {
|
||||
version: '0.8.3',
|
||||
},
|
||||
cameras: {
|
||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
|
||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
|
||||
},
|
||||
mqtt: {
|
||||
stats_interva: 60,
|
||||
},
|
||||
},
|
||||
status: 'loaded',
|
||||
}));
|
||||
useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => ({ data: statsMock }));
|
||||
useMqttMock = jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: null } }));
|
||||
});
|
||||
beforeEach(() => {});
|
||||
|
||||
test('shows an ActivityIndicator if stats are null', async () => {
|
||||
useStatsMock.mockReturnValue({ data: null });
|
||||
render(<Debug />);
|
||||
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows stats and config', async () => {
|
||||
render(<Debug />);
|
||||
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
expect(screen.queryByTestId('detectors')).toBeInTheDocument();
|
||||
expect(screen.queryByText('coral')).toBeInTheDocument();
|
||||
@ -47,32 +25,4 @@ describe('Debug Route', () => {
|
||||
expect(screen.queryByText('Config')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates the stats from mqtt', async () => {
|
||||
const { rerender } = render(<Debug />);
|
||||
expect(useMqttMock).toHaveBeenCalledWith('stats');
|
||||
useMqttMock.mockReturnValue({
|
||||
value: {
|
||||
payload: { ...statsMock, detectors: { coral: { ...statsMock.detectors.coral, inference_speed: 42.4242 } } },
|
||||
},
|
||||
});
|
||||
rerender(<Debug />);
|
||||
|
||||
expect(screen.queryByText('42.4242')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const statsMock = {
|
||||
detection_fps: 0.0,
|
||||
detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
|
||||
front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
|
||||
side: {
|
||||
camera_fps: 6.9,
|
||||
capture_pid: 71,
|
||||
detection_fps: 0.0,
|
||||
pid: 60,
|
||||
process_fps: 0.0,
|
||||
skipped_fps: 0.0,
|
||||
},
|
||||
service: { uptime: 34812, version: '0.8.1-d376f6b' },
|
||||
};
|
||||
|
@ -1,28 +1,9 @@
|
||||
import { h } from 'preact';
|
||||
import * as Api from '../../api';
|
||||
import * as Hooks from '../../hooks';
|
||||
import Events from '../Events';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
|
||||
|
||||
describe('Events Route', () => {
|
||||
let useEventsMock, useIntersectionMock;
|
||||
|
||||
beforeEach(() => {
|
||||
useEventsMock = jest.spyOn(Api, 'useEvents').mockImplementation(() => ({
|
||||
data: null,
|
||||
status: 'loading',
|
||||
}));
|
||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: {
|
||||
cameras: {
|
||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, zones: [] },
|
||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, zones: [] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://localhost:5000');
|
||||
useIntersectionMock = jest.spyOn(Hooks, 'useIntersectionObserver').mockImplementation(() => [null, jest.fn()]);
|
||||
});
|
||||
beforeEach(() => {});
|
||||
|
||||
test('shows an ActivityIndicator if not yet loaded', async () => {
|
||||
render(<Events limit={5} path="/events" />);
|
||||
@ -30,53 +11,10 @@ describe('Events Route', () => {
|
||||
});
|
||||
|
||||
test('does not show ActivityIndicator after loaded', async () => {
|
||||
useEventsMock.mockReturnValue({ data: mockEvents, status: 'loaded' });
|
||||
render(<Events limit={5} path="/events" />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
|
||||
|
||||
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loads more when the intersectionObserver fires', async () => {
|
||||
const setIntersectionNode = jest.fn();
|
||||
useIntersectionMock.mockReturnValue([null, setIntersectionNode]);
|
||||
useEventsMock.mockImplementation((searchString) => {
|
||||
if (searchString.includes('before=')) {
|
||||
const params = new URLSearchParams(searchString);
|
||||
const before = parseFloat(params.get('before'));
|
||||
const index = mockEvents.findIndex((el) => el.start_time === before + 0.0001);
|
||||
return { data: mockEvents.slice(index, index + 5), status: 'loaded' };
|
||||
}
|
||||
|
||||
return { data: mockEvents.slice(0, 5), status: 'loaded' };
|
||||
});
|
||||
|
||||
const { rerender } = render(<Events limit={5} path="/events" />);
|
||||
expect(setIntersectionNode).toHaveBeenCalled();
|
||||
expect(useEventsMock).toHaveBeenCalledWith('include_thumbnails=0&limit=5&');
|
||||
expect(screen.queryAllByTestId(/event-\d+/)).toHaveLength(5);
|
||||
|
||||
useIntersectionMock.mockReturnValue([
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: { dataset: { startTime: mockEvents[4].start_time } },
|
||||
},
|
||||
setIntersectionNode,
|
||||
]);
|
||||
rerender(<Events limit={5} path="/events" />);
|
||||
expect(useEventsMock).toHaveBeenCalledWith(
|
||||
`include_thumbnails=0&limit=5&before=${mockEvents[4].start_time - 0.0001}`
|
||||
);
|
||||
expect(screen.queryAllByTestId(/event-\d+/)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
const mockEvents = 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...',
|
||||
}));
|
||||
|
@ -70,4 +70,5 @@ export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset:
|
||||
const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch);
|
||||
return timelineDurationLong + offset * 2;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
1
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
@ -1,30 +1,24 @@
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
content: [
|
||||
"./public/**/*.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: '480px',
|
||||
'2xl': '1536px',
|
||||
'3xl': '1720px',
|
||||
xs: "480px",
|
||||
"2xl": "1536px",
|
||||
"3xl": "1720px",
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
sm: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||
DEFAULT: '0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%);',
|
||||
md: '0px 2px 2px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%);',
|
||||
lg: '0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23)',
|
||||
xl: '0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22)',
|
||||
'2xl': '0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22)',
|
||||
'3xl': '',
|
||||
none: '',
|
||||
sm: "0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)",
|
||||
DEFAULT:
|
||||
"0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%);",
|
||||
md: "0px 2px 2px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%);",
|
||||
lg: "0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23)",
|
||||
xl: "0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22)",
|
||||
"2xl": "0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22)",
|
||||
"3xl": "",
|
||||
none: "",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
|
@ -1,28 +1,23 @@
|
||||
{
|
||||
"include": ["./src/**/*.tsx", "./src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"baseUrl": "./",
|
||||
/* paths - import rewriting/resolving */
|
||||
"paths": {
|
||||
// If you configured any Snowpack aliases, add them here.
|
||||
// Add this line to get types for streaming imports (packageOptions.source="remote"):
|
||||
// "*": [".snowpack/types/*"]
|
||||
// More info: https://www.snowpack.dev/guides/streaming-imports
|
||||
},
|
||||
/* noEmit - Snowpack builds (emits) files, not tsc. */
|
||||
"noEmit": true,
|
||||
/* Additional Options */
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
// "types": ["mocha", "snowpack-env"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"importsNotUsedAsValues": "error"
|
||||
}
|
||||
}
|
||||
"jsxFragmentFactory": "Fragment"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
8
web/tsconfig.node.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
web/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import preact from '@preact/preset-vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
})
|