Initial framework for new UI with React/Typescript (#8885)
* Write a low resolution low fps stream from decoded frames (#8673) * Generate low res low fps previews for recordings viewer * Make sure previews end on the hour * Fix durations and decrase keyframe interval to ensure smooth scrubbing * Ensure minimized resolution is compatible with yuv * Add ability to configure preview quality * Fix * Clean up previews more efficiently * Use iterator * Ensure final frame in preview is not duplicated * initial react/ts framework * fix gitignore glob excluding ts files * ignore folders in web-new * SWRConfig changes for swr 2.x * use frigateConfig type in websocket handlers --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
4
.gitignore
vendored
@ -8,13 +8,15 @@ config/*
|
||||
!config/*.example
|
||||
models
|
||||
*.mp4
|
||||
*.ts
|
||||
*.db
|
||||
*.csv
|
||||
frigate/version.py
|
||||
web/build
|
||||
web/node_modules
|
||||
web/coverage
|
||||
web-new/build
|
||||
web-new/node_modules
|
||||
web-new/coverage
|
||||
core
|
||||
!/web/**/*.ts
|
||||
.idea/*
|
57
web-new/.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",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
24
web-new/.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?
|
30
web-new/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
|
16
web-new/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"
|
||||
}
|
||||
}
|
BIN
web-new/images/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
web-new/images/favicon-16x16.png
Normal file
After Width: | Height: | Size: 558 B |
BIN
web-new/images/favicon-32x32.png
Normal file
After Width: | Height: | Size: 800 B |
BIN
web-new/images/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
web-new/images/favicon.png
Normal file
After Width: | Height: | Size: 12 KiB |
46
web-new/images/favicon.svg
Normal file
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="888.000000pt" height="888.000000pt" viewBox="0 0 888.000000 888.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,888.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M8228 8865 c-2 -2 -25 -6 -53 -9 -38 -5 -278 -56 -425 -91 -33 -7
|
||||
-381 -98 -465 -121 -49 -14 -124 -34 -165 -45 -67 -18 -485 -138 -615 -176
|
||||
-50 -14 -106 -30 -135 -37 -8 -2 -35 -11 -60 -19 -25 -8 -85 -27 -135 -42 -49
|
||||
-14 -101 -31 -115 -36 -14 -5 -34 -11 -45 -13 -11 -3 -65 -19 -120 -36 -55
|
||||
-18 -127 -40 -160 -50 -175 -53 -247 -77 -550 -178 -364 -121 -578 -200 -820
|
||||
-299 -88 -36 -214 -88 -280 -115 -66 -27 -129 -53 -140 -58 -11 -5 -67 -29
|
||||
-125 -54 -342 -144 -535 -259 -579 -343 -34 -66 7 -145 156 -299 229 -238 293
|
||||
-316 340 -413 38 -80 41 -152 10 -281 -57 -234 -175 -543 -281 -732 -98 -174
|
||||
-172 -239 -341 -297 -116 -40 -147 -52 -210 -80 -107 -49 -179 -107 -290 -236
|
||||
-51 -59 -179 -105 -365 -131 -19 -2 -48 -7 -65 -9 -16 -3 -50 -8 -75 -11 -69
|
||||
-9 -130 -39 -130 -63 0 -24 31 -46 78 -56 18 -4 139 -8 270 -10 250 -4 302
|
||||
-11 335 -44 19 -18 19 -23 7 -46 -19 -36 -198 -121 -490 -233 -850 -328 -914
|
||||
-354 -1159 -473 -185 -90 -337 -186 -395 -249 -60 -65 -67 -107 -62 -350 3
|
||||
-113 7 -216 10 -230 3 -14 7 -52 10 -85 7 -70 14 -128 21 -170 2 -16 7 -48 10
|
||||
-70 3 -22 11 -64 16 -94 6 -30 12 -64 14 -75 1 -12 5 -34 9 -51 3 -16 8 -39
|
||||
10 -50 12 -57 58 -258 71 -310 9 -33 18 -69 20 -79 25 -110 138 -416 216 -582
|
||||
21 -47 39 -87 39 -90 0 -7 217 -438 261 -521 109 -201 293 -501 347 -564 11
|
||||
-13 37 -44 56 -68 69 -82 126 -109 160 -75 26 25 14 65 -48 164 -138 218 -142
|
||||
245 -138 800 2 206 4 488 5 625 1 138 -1 293 -6 345 -28 345 -28 594 -1 760
|
||||
12 69 54 187 86 235 33 52 188 212 293 302 98 84 108 93 144 121 19 15 52 42
|
||||
75 61 78 64 302 229 426 313 248 169 483 297 600 326 53 14 205 6 365 -17 33
|
||||
-5 155 -8 270 -6 179 3 226 7 316 28 58 13 140 25 182 26 82 2 120 6 217 22
|
||||
73 12 97 16 122 18 12 1 23 21 38 70 l20 68 74 -17 c81 -20 155 -30 331 -45
|
||||
69 -6 132 -8 715 -20 484 -11 620 -8 729 16 85 19 131 63 98 96 -25 26 -104
|
||||
34 -302 32 -373 -2 -408 -1 -471 26 -90 37 2 102 171 120 33 3 76 8 95 10 19
|
||||
2 71 7 115 10 243 17 267 20 338 37 145 36 47 102 -203 137 -136 19 -262 25
|
||||
-490 22 -124 -2 -362 -4 -530 -4 l-305 -1 -56 26 c-65 31 -171 109 -238 176
|
||||
-52 51 -141 173 -141 191 0 6 -6 22 -14 34 -18 27 -54 165 -64 244 -12 98 -6
|
||||
322 12 414 9 47 29 127 45 176 26 80 58 218 66 278 1 11 6 47 10 80 3 33 8 70
|
||||
10 83 2 13 7 53 11 90 3 37 8 74 9 83 22 118 22 279 -1 464 -20 172 -20 172
|
||||
70 238 108 79 426 248 666 355 25 11 77 34 115 52 92 42 443 191 570 242 55
|
||||
22 109 44 120 48 24 11 130 52 390 150 199 75 449 173 500 195 17 7 118 50
|
||||
225 95 237 100 333 143 490 220 229 113 348 191 337 223 -3 10 -70 20 -79 12z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
BIN
web-new/images/marker.png
Normal file
After Width: | Height: | Size: 534 B |
BIN
web-new/images/mstile-150x150.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
33
web-new/index.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/images/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frigate</title>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/images/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/images/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/images/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/images/favicon.svg" color="#3b82f7" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
7979
web-new/package-lock.json
generated
Normal file
86
web-new/package.json
Normal file
@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "web-new",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
|
||||
"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",
|
||||
"@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-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",
|
||||
"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.1",
|
||||
"immer": "^10.0.3",
|
||||
"lucide-react": "^0.294.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",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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": "^5.0.1",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"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": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
6
web-new/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
web-new/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 |
53
web-new/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 className="overflow-x-hidden px-4 py-2">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/live" 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;
|
7
web-new/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 || '/'}`;
|
48
web-new/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;
|
||||
}
|
196
web-new/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 };
|
||||
}
|
305
web-new/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;
|
7
web-new/src/components/Logo.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function Logo() {
|
||||
return (
|
||||
<svg viewBox="0 0 512 512" className="fill-current">
|
||||
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.5 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
101
web-new/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;
|
11
web-new/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;
|
12
web-new/src/components/ui/activity-indicator.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { LuLoader2 } from "react-icons/lu";
|
||||
|
||||
export default function ActivityIndicator({ size = 30 }) {
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center justify-center"
|
||||
aria-label="Loading…"
|
||||
>
|
||||
<LuLoader2 className="animate-spin" size={size} />
|
||||
</div>
|
||||
);
|
||||
}
|
139
web-new/src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
5
web-new/src/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
36
web-new/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
56
web-new/src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
64
web-new/src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
79
web-new/src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
120
web-new/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
198
web-new/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
176
web-new/src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
73
web-new/src/components/ui/heading.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HeadingElements = "h1" | "h2" | "h3" | "h4";
|
||||
|
||||
const Heading = ({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
as: HeadingElements;
|
||||
className?: string;
|
||||
}) => {
|
||||
switch (as) {
|
||||
case "h1":
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"scroll-m-20 text-3xl font-extrabold tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
case "h2":
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0 mb-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
case "h3":
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"scroll-m-20 text-2xl font-semibold tracking-tight mb-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
case "h4":
|
||||
return (
|
||||
<h4
|
||||
className={cn(
|
||||
"scroll-m-20 text-xl font-semibold tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"scroll-m-20 text-3xl font-extrabold tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Heading;
|
25
web-new/src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
24
web-new/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
29
web-new/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
42
web-new/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
158
web-new/src/components/ui/select.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
138
web-new/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
26
web-new/src/components/ui/slider.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
27
web-new/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
53
web-new/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
76
web-new/src/components/ui/text.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextElements =
|
||||
| "p"
|
||||
| "blockquote"
|
||||
| "code"
|
||||
| "lead"
|
||||
| "large"
|
||||
| "small"
|
||||
| "muted";
|
||||
|
||||
const Text = ({
|
||||
children,
|
||||
as,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
as: TextElements;
|
||||
className?: string;
|
||||
}) => {
|
||||
switch (as) {
|
||||
case "p":
|
||||
return (
|
||||
<p className={cn("leading-7 [&:not(:first-child)]:mt-6", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
case "blockquote":
|
||||
return (
|
||||
<blockquote className={cn("mt-6 border-l-2 pl-6 italic", className)}>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
case "lead":
|
||||
return (
|
||||
<p className={cn("text-xl text-muted-foreground", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
case "large":
|
||||
return (
|
||||
<div className={cn("text-lg font-semibold", className)}>{children}</div>
|
||||
);
|
||||
case "small":
|
||||
return (
|
||||
<small className={cn("text-sm font-medium leading-none", className)}>
|
||||
{children}
|
||||
</small>
|
||||
);
|
||||
case "muted":
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)}>
|
||||
{children}.
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<p className={cn("leading-7 [&:not(:first-child)]:mt-6", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Text;
|
25
web-new/src/context/providers.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/context/theme-provider";
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { ApiProvider } from "@/api";
|
||||
import { IconContext } from "react-icons";
|
||||
|
||||
type TProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function providers({ children }: TProvidersProps) {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<ApiProvider>
|
||||
<ThemeProvider defaultTheme="light" storageKey="frigate-ui-theme">
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
{children}
|
||||
</IconContext.Provider>
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default providers;
|
136
web-new/src/context/theme-provider.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
type ColorScheme =
|
||||
| "theme-blue"
|
||||
| "theme-gold"
|
||||
| "theme-green"
|
||||
| "theme-nature"
|
||||
| "theme-netflix"
|
||||
| "theme-nord"
|
||||
| "theme-orange"
|
||||
| "theme-red"
|
||||
| "theme-default";
|
||||
|
||||
export const colorSchemes: ColorScheme[] = [
|
||||
"theme-blue",
|
||||
"theme-gold",
|
||||
"theme-green",
|
||||
"theme-nature",
|
||||
"theme-netflix",
|
||||
"theme-nord",
|
||||
"theme-orange",
|
||||
"theme-red",
|
||||
"theme-default",
|
||||
];
|
||||
|
||||
// Helper function to generate friendly color scheme names
|
||||
export const friendlyColorSchemeName = (className: string): string => {
|
||||
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
|
||||
return words
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
defaultColorScheme?: ColorScheme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
colorScheme: ColorScheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setColorScheme: (colorScheme: ColorScheme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
colorScheme: "theme-default",
|
||||
setTheme: () => null,
|
||||
setColorScheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
defaultColorScheme = "theme-default",
|
||||
storageKey = "frigate-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
try {
|
||||
const storedData = JSON.parse(localStorage.getItem(storageKey) || "{}");
|
||||
return storedData.theme || defaultTheme;
|
||||
} catch (error) {
|
||||
console.error("Error parsing theme data from storage:", error);
|
||||
return defaultTheme;
|
||||
}
|
||||
});
|
||||
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(() => {
|
||||
try {
|
||||
const storedData = JSON.parse(localStorage.getItem(storageKey) || "{}");
|
||||
return storedData.colorScheme === "default"
|
||||
? defaultColorScheme
|
||||
: storedData.colorScheme || defaultColorScheme;
|
||||
} catch (error) {
|
||||
console.error("Error parsing color scheme data from storage:", error);
|
||||
return defaultColorScheme;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
//localStorage.removeItem(storageKey);
|
||||
//console.log(localStorage.getItem(storageKey));
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark", "system", ...colorSchemes);
|
||||
|
||||
root.classList.add(theme, colorScheme);
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme, colorScheme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
colorScheme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ theme, colorScheme }));
|
||||
setTheme(theme);
|
||||
},
|
||||
setColorScheme: (colorScheme: ColorScheme) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ theme, colorScheme }));
|
||||
setColorScheme(colorScheme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
39
web-new/src/context/use-persistence.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { get as getData, set as setData } from "idb-keyval";
|
||||
|
||||
export function usePersistence(
|
||||
key: string,
|
||||
defaultValue: string | boolean | undefined = undefined
|
||||
) {
|
||||
const [value, setInternalValue] = useState(defaultValue);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: string | boolean) => {
|
||||
setInternalValue(value);
|
||||
async function update() {
|
||||
await setData(key, value);
|
||||
}
|
||||
|
||||
update();
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setInternalValue(defaultValue);
|
||||
|
||||
async function load() {
|
||||
const value = await getData(key);
|
||||
if (typeof value !== "undefined") {
|
||||
setValue(value);
|
||||
}
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [key, defaultValue, setValue]);
|
||||
|
||||
return [value, setValue, loaded];
|
||||
}
|
24
web-new/src/index.css
Normal file
@ -0,0 +1,24 @@
|
||||
@import "/themes/theme-default.css";
|
||||
@import "/themes/theme-blue.css";
|
||||
@import "/themes/theme-gold.css";
|
||||
@import "/themes/theme-green.css";
|
||||
@import "/themes/theme-nature.css";
|
||||
@import "/themes/theme-netflix.css";
|
||||
@import "/themes/theme-nord.css";
|
||||
@import "/themes/theme-orange.css";
|
||||
@import "/themes/theme-red.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Duplicated var names with color space modifiers for swatches in vscode. See https://github.com/tailwindlabs/tailwindcss/discussions/10619 */
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
649
web-new/src/lib/MsePlayer.js
Normal file
@ -0,0 +1,649 @@
|
||||
class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 30000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = 'webrtc,mse,mp4,mjpeg';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
this.video = null;
|
||||
|
||||
/**
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
*/
|
||||
this.pc = null;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* [internal] Handlers list for receiving JSON from WebSocket
|
||||
* @type {Object.<string,Function>}}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = `ws${value.substring(4)}`;
|
||||
} else if (value.startsWith('/')) {
|
||||
value = `ws${location.origin.substring(4)}${value}`;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video. Support automute when autoplay blocked.
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch((er) => {
|
||||
if (er.name === 'NotAllowedError' && !this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(() => { });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server via WebSocket
|
||||
* @param {Object} value
|
||||
*/
|
||||
send(value) {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
if (this.video) {
|
||||
const seek = this.video.seekable;
|
||||
if (seek.length > 0) {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
}
|
||||
this.play();
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
this.video.muted = true;
|
||||
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: this.visibilityThreshold }
|
||||
);
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', (ev) => this.onopen(ev));
|
||||
this.ws.addEventListener('close', (ev) => this.onclose(ev));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
// iPhone
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
|
||||
// macOS Desktop app
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = (msg) => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
};
|
||||
} else {
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
/** @type {MediaSource} */
|
||||
let ms;
|
||||
|
||||
if ('ManagedMediaSource' in window) {
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.disableRemotePlayback = true;
|
||||
this.video.srcObject = ms;
|
||||
} else {
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage['mse'] = (msg) => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (sb.updating) return;
|
||||
|
||||
try {
|
||||
if (bufLen > 0) {
|
||||
const data = buf.slice(0, bufLen);
|
||||
bufLen = 0;
|
||||
sb.appendBuffer(data);
|
||||
} else if (sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
|
||||
const start = sb.buffered.start(0);
|
||||
if (end > start) {
|
||||
sb.remove(start, end);
|
||||
ms.setLiveSeekableRange(end, end + 15);
|
||||
}
|
||||
// console.debug("VideoRTC.buffered", start, end);
|
||||
}
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||
let bufLen = 0;
|
||||
|
||||
this.ondata = (data) => {
|
||||
if (sb.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
bufLen += b.byteLength;
|
||||
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
|
||||
} else {
|
||||
try {
|
||||
sb.appendBuffer(data);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true });
|
||||
|
||||
pc.addEventListener('icecandidate', (ev) => {
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({ type: 'webrtc/candidate', value: candidate });
|
||||
});
|
||||
|
||||
pc.addEventListener('track', (ev) => {
|
||||
// when stream already init
|
||||
if (video2.srcObject !== null) return;
|
||||
|
||||
// when audio track not exist in Chrome
|
||||
if (ev.streams.length === 0) return;
|
||||
|
||||
// when audio track not exist in Firefox
|
||||
if (ev.streams[0].id[0] === '{') return;
|
||||
|
||||
video2.srcObject = ev.streams[0];
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage['webrtc'] = (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
pc.addIceCandidate({
|
||||
candidate: msg.value,
|
||||
sdpMid: '0',
|
||||
}).catch(() => { });
|
||||
break;
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: msg.value,
|
||||
}).catch(() => { });
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari doesn't support "offerToReceiveVideo"
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
pc.createOffer().then((offer) => {
|
||||
pc.setLocalDescription(offer).then(() => {
|
||||
this.send({ type: 'webrtc/offer', value: offer.sdp });
|
||||
});
|
||||
});
|
||||
|
||||
this.pcState = WebSocket.CONNECTING;
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ev {Event}
|
||||
*/
|
||||
onpcvideo(ev) {
|
||||
if (!this.pc) return;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = ev.target;
|
||||
const state = this.pc.connectionState;
|
||||
|
||||
// Firefox doesn't support pc.connectionState
|
||||
if (state === 'connected' || state === 'connecting' || !state) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0,
|
||||
msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const ms = video2.srcObject;
|
||||
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = ms;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = (data) => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = `data:image/jpeg;base64,${VideoRTC.btoa(data)}`;
|
||||
};
|
||||
|
||||
this.send({ type: 'mjpeg' });
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener('loadeddata', (_) => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = (data) => {
|
||||
video2.src = `data:video/mp4;base64,${VideoRTC.btoa(data)}`;
|
||||
};
|
||||
|
||||
this.send({ type: 'mp4', value: this.codecs(this.video.canPlayType) });
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoStream extends VideoRTC {
|
||||
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
super.oninit();
|
||||
const info = this.querySelector('.info');
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = 'loading';
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {;
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage['stream'] = (_) => {
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = 'RTC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('video-stream', VideoStream);
|
25
web-new/src/lib/formatTimeAgo.ts
Normal file
@ -0,0 +1,25 @@
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, {
|
||||
numeric: "always",
|
||||
})
|
||||
|
||||
const DIVISIONS: { amount: number; name: Intl.RelativeTimeFormatUnit }[] = [
|
||||
{ amount: 60, name: "seconds" },
|
||||
{ amount: 60, name: "minutes" },
|
||||
{ amount: 24, name: "hours" },
|
||||
{ amount: 7, name: "days" },
|
||||
{ amount: 4.34524, name: "weeks" },
|
||||
{ amount: 12, name: "months" },
|
||||
{ amount: Number.POSITIVE_INFINITY, name: "years" },
|
||||
]
|
||||
|
||||
export function formatTimeAgo(date: Date) {
|
||||
let duration = (date.getTime() - new Date().getTime()) / 1000
|
||||
|
||||
for (let i = 0; i < DIVISIONS.length; i++) {
|
||||
const division = DIVISIONS[i]
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return formatter.format(Math.round(duration), division.name)
|
||||
}
|
||||
duration /= division.amount
|
||||
}
|
||||
}
|
6
web-new/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
10
web-new/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
11
web-new/src/pages/ConfigEditor.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function ConfigEditor() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Configuration Editor</Heading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigEditor;
|
81
web-new/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useDetectState } from "@/api/ws";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
export function Dashboard() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [selectedCamera, setSelectedCamera] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
let cameras;
|
||||
if (config?.cameras) {
|
||||
cameras = Object.keys(config.cameras).map((name) => (
|
||||
<div key={name}>
|
||||
<SelectItem value={name} onClick={() => setSelectedCamera(name)}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!config && <ActivityIndicator />}
|
||||
|
||||
<Heading as="h2">Components testing</Heading>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Select
|
||||
value={selectedCamera}
|
||||
onValueChange={(val) => setSelectedCamera(val as string)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Choose camera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>{cameras}</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedCamera && <Camera cameraName={selectedCamera} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Camera({ cameraName }: { cameraName: string }) {
|
||||
const { payload: detectValue, send: sendDetect } = useDetectState(cameraName);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h3" className="mt-5">
|
||||
{cameraName}
|
||||
</Heading>
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Switch
|
||||
id={`detect-${cameraName}`}
|
||||
checked={detectValue === "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendDetect(detectValue === "ON" ? "OFF" : "ON", true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`detect-${cameraName}`}>Detect</Label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
11
web-new/src/pages/Export.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function Export() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Export</Heading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Export;
|
11
web-new/src/pages/History.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function History() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">History</Heading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default History;
|
11
web-new/src/pages/Live.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function Live() {
|
||||
return (
|
||||
<div>
|
||||
<Heading as="h2">Birdseye</Heading>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Live;
|
11
web-new/src/pages/Logs.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function Logs() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Logs</Heading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Logs;
|
12
web-new/src/pages/NoMatch.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function NoMatch() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">404</Heading>
|
||||
<p>Page not found</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoMatch;
|
44
web-new/src/pages/Settings.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
function Settings() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Settings</Heading>
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
|
||||
<Label htmlFor="detect">
|
||||
Always show PTZ controls for ONVIF cameras
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Default Live Mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Live Mode</SelectLabel>
|
||||
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
|
||||
<SelectItem value="mse">MSE</SelectItem>
|
||||
<SelectItem value="webrtc">WebRTC</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
11
web-new/src/pages/Storage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function Storage() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Storage</Heading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Storage;
|
11
web-new/src/pages/System.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
function System() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">System</Heading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default System;
|
402
web-new/src/types/frigateConfig.ts
Normal file
@ -0,0 +1,402 @@
|
||||
export interface FrigateConfig {
|
||||
audio: {
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean | null;
|
||||
filters: string[] | null;
|
||||
listen: string[];
|
||||
max_not_heard: number;
|
||||
min_volume: number;
|
||||
num_threads: number;
|
||||
};
|
||||
|
||||
birdseye: {
|
||||
enabled: boolean;
|
||||
height: number;
|
||||
mode: "objects";
|
||||
quality: number;
|
||||
restream: boolean;
|
||||
width: number;
|
||||
};
|
||||
|
||||
cameras: {
|
||||
[cameraName: string]: {
|
||||
audio: {
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean;
|
||||
filters: string[] | null;
|
||||
listen: string[];
|
||||
max_not_heard: number;
|
||||
min_volume: number;
|
||||
num_threads: number;
|
||||
};
|
||||
best_image_timeout: number;
|
||||
birdseye: {
|
||||
enabled: boolean;
|
||||
mode: "objects";
|
||||
order: number;
|
||||
};
|
||||
detect: {
|
||||
annotation_offset: number;
|
||||
enabled: boolean;
|
||||
fps: number;
|
||||
height: number;
|
||||
max_disappeared: number;
|
||||
min_initialized: number;
|
||||
stationary: {
|
||||
interval: number;
|
||||
max_frames: {
|
||||
default: number | null;
|
||||
objects: Record<string, unknown>;
|
||||
};
|
||||
threshold: number;
|
||||
};
|
||||
width: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
ffmpeg: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string;
|
||||
input_args: string;
|
||||
inputs: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string[];
|
||||
input_args: string;
|
||||
path: string;
|
||||
roles: string[];
|
||||
}[];
|
||||
output_args: {
|
||||
detect: string[];
|
||||
record: string;
|
||||
rtmp: string;
|
||||
};
|
||||
retry_interval: number;
|
||||
};
|
||||
ffmpeg_cmds: {
|
||||
cmd: string;
|
||||
roles: string[];
|
||||
}[];
|
||||
live: {
|
||||
height: number;
|
||||
quality: number;
|
||||
stream_name: string;
|
||||
};
|
||||
motion: {
|
||||
contour_area: number;
|
||||
delta_alpha: number;
|
||||
frame_alpha: number;
|
||||
frame_height: number;
|
||||
improve_contrast: boolean;
|
||||
lightning_threshold: number;
|
||||
mask: string[];
|
||||
mqtt_off_delay: number;
|
||||
threshold: number;
|
||||
};
|
||||
mqtt: {
|
||||
bounding_box: boolean;
|
||||
crop: boolean;
|
||||
enabled: boolean;
|
||||
height: number;
|
||||
quality: number;
|
||||
required_zones: string[];
|
||||
timestamp: boolean;
|
||||
};
|
||||
name: string;
|
||||
objects: {
|
||||
filters: {
|
||||
[objectName: string]: {
|
||||
mask: string | null;
|
||||
max_area: number;
|
||||
max_ratio: number;
|
||||
min_area: number;
|
||||
min_ratio: number;
|
||||
min_score: number;
|
||||
threshold: number;
|
||||
};
|
||||
};
|
||||
mask: string;
|
||||
track: string[];
|
||||
};
|
||||
onvif: {
|
||||
autotracking: {
|
||||
calibrate_on_startup: boolean,
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean;
|
||||
movement_weights: string[];
|
||||
required_zones: string[];
|
||||
return_preset: string;
|
||||
timeout: number;
|
||||
track: string[];
|
||||
zoom_factor: number;
|
||||
zooming: string;
|
||||
};
|
||||
host: string;
|
||||
password: string | null;
|
||||
port: number;
|
||||
user: string | null;
|
||||
};
|
||||
record: {
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean;
|
||||
events: {
|
||||
objects: string[] | null;
|
||||
post_capture: number;
|
||||
pre_capture: number;
|
||||
required_zones: string[];
|
||||
retain: {
|
||||
default: number;
|
||||
mode: string;
|
||||
objects: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
expire_interval: number;
|
||||
export: {
|
||||
timelapse_args: string;
|
||||
};
|
||||
preview: {
|
||||
quality: string;
|
||||
};
|
||||
retain: {
|
||||
days: number;
|
||||
mode: string;
|
||||
};
|
||||
sync_recordings: boolean;
|
||||
};
|
||||
rtmp: {
|
||||
enabled: boolean;
|
||||
};
|
||||
snapshots: {
|
||||
bounding_box: boolean;
|
||||
clean_copy: boolean;
|
||||
crop: boolean;
|
||||
enabled: boolean;
|
||||
height: number | null;
|
||||
quality: number;
|
||||
required_zones: string[];
|
||||
retain: {
|
||||
default: number;
|
||||
mode: string;
|
||||
objects: Record<string, unknown>;
|
||||
};
|
||||
timestamp: boolean;
|
||||
};
|
||||
timestamp_style: {
|
||||
color: {
|
||||
blue: number;
|
||||
green: number;
|
||||
red: number;
|
||||
};
|
||||
effect: string | null;
|
||||
format: string;
|
||||
position: string;
|
||||
thickness: number;
|
||||
};
|
||||
ui: {
|
||||
dashboard: boolean;
|
||||
order: number;
|
||||
};
|
||||
webui_url: string | null;
|
||||
zones: {
|
||||
[zoneName: string]: {
|
||||
coordinates: string;
|
||||
filters: Record<string, unknown>;
|
||||
inertia: number;
|
||||
objects: any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
database: {
|
||||
path: string;
|
||||
};
|
||||
|
||||
detect: {
|
||||
annotation_offset: number;
|
||||
enabled: boolean;
|
||||
fps: number;
|
||||
height: number | null;
|
||||
max_disappeared: number | null;
|
||||
min_initialized: number | null;
|
||||
stationary: {
|
||||
interval: number | null;
|
||||
max_frames: {
|
||||
default: number | null;
|
||||
objects: Record<string, unknown>;
|
||||
};
|
||||
threshold: number | null;
|
||||
};
|
||||
width: number | null;
|
||||
};
|
||||
|
||||
detectors: {
|
||||
coral: {
|
||||
device: string;
|
||||
model: {
|
||||
height: number;
|
||||
input_pixel_format: string;
|
||||
input_tensor: string;
|
||||
labelmap: Record<string, string>;
|
||||
labelmap_path: string | null;
|
||||
model_type: string;
|
||||
path: string;
|
||||
width: number;
|
||||
};
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
environment_vars: Record<string, unknown>;
|
||||
|
||||
ffmpeg: {
|
||||
global_args: string[];
|
||||
hwaccel_args: string;
|
||||
input_args: string;
|
||||
output_args: {
|
||||
detect: string[];
|
||||
record: string;
|
||||
rtmp: string;
|
||||
};
|
||||
retry_interval: number;
|
||||
};
|
||||
|
||||
go2rtc: Record<string, unknown>;
|
||||
|
||||
live: {
|
||||
height: number;
|
||||
quality: number;
|
||||
stream_name: string;
|
||||
};
|
||||
|
||||
logger: {
|
||||
default: string;
|
||||
logs: Record<string, string>;
|
||||
};
|
||||
|
||||
model: {
|
||||
height: number;
|
||||
input_pixel_format: string;
|
||||
input_tensor: string;
|
||||
labelmap: Record<string, unknown>;
|
||||
labelmap_path: string | null;
|
||||
model_type: string;
|
||||
path: string | null;
|
||||
width: number;
|
||||
};
|
||||
|
||||
motion: Record<string, unknown> | null;
|
||||
|
||||
mqtt: {
|
||||
client_id: string;
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
stats_interval: number;
|
||||
tls_ca_certs: string | null;
|
||||
tls_client_cert: string | null;
|
||||
tls_client_key: string | null;
|
||||
tls_insecure: boolean | null;
|
||||
topic_prefix: string;
|
||||
user: string | null;
|
||||
};
|
||||
|
||||
objects: {
|
||||
filters: {
|
||||
[objectName: string]: {
|
||||
mask: string | null;
|
||||
max_area: number;
|
||||
max_ratio: number;
|
||||
min_area: number;
|
||||
min_ratio: number;
|
||||
min_score: number;
|
||||
threshold: number;
|
||||
};
|
||||
};
|
||||
mask: string;
|
||||
track: string[];
|
||||
};
|
||||
|
||||
plus: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
record: {
|
||||
enabled: boolean;
|
||||
enabled_in_config: boolean | null;
|
||||
events: {
|
||||
objects: string[] | null;
|
||||
post_capture: number;
|
||||
pre_capture: number;
|
||||
required_zones: string[];
|
||||
retain: {
|
||||
default: number;
|
||||
mode: string;
|
||||
objects: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
expire_interval: number;
|
||||
export: {
|
||||
timelapse_args: string;
|
||||
};
|
||||
preview: {
|
||||
quality: string;
|
||||
};
|
||||
retain: {
|
||||
days: number;
|
||||
mode: string;
|
||||
};
|
||||
sync_recordings: boolean;
|
||||
};
|
||||
|
||||
rtmp: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
snapshots: {
|
||||
bounding_box: boolean;
|
||||
clean_copy: boolean;
|
||||
crop: boolean;
|
||||
enabled: boolean;
|
||||
height: number | null;
|
||||
quality: number;
|
||||
required_zones: string[];
|
||||
retain: {
|
||||
default: number;
|
||||
mode: string;
|
||||
objects: Record<string, unknown>;
|
||||
};
|
||||
timestamp: boolean;
|
||||
};
|
||||
|
||||
telemetry: {
|
||||
network_interfaces: any[];
|
||||
stats: {
|
||||
amd_gpu_stats: boolean;
|
||||
intel_gpu_stats: boolean;
|
||||
network_bandwidth: boolean;
|
||||
};
|
||||
version_check: boolean;
|
||||
};
|
||||
|
||||
timestamp_style: {
|
||||
color: {
|
||||
blue: number;
|
||||
green: number;
|
||||
red: number;
|
||||
};
|
||||
effect: string | null;
|
||||
format: string;
|
||||
position: string;
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
ui: {
|
||||
date_style: string;
|
||||
live_mode: string;
|
||||
strftime_fmt: string | null;
|
||||
time_format: string;
|
||||
time_style: string;
|
||||
timezone: string | null;
|
||||
use_experimental: boolean;
|
||||
};
|
||||
|
||||
}
|
1
web-new/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
76
web-new/tailwind.config.js
Normal file
@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
46
web-new/themes/theme-blue.css
Normal file
@ -0,0 +1,46 @@
|
||||
@layer base {
|
||||
.theme-blue.light {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-blue.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
121
web-new/themes/theme-default.css
Normal file
@ -0,0 +1,121 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background-hsl: hsl(0 0% 100%);
|
||||
--background: 0 0% 100%;
|
||||
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: hsl(0 0% 100%);
|
||||
--card: 0 0% 100%;
|
||||
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover: 0 0% 100%;
|
||||
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: hsl(222.2 47.4% 11.2%);
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary: 210 40% 96.1%;
|
||||
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted: 210 40% 96.1%;
|
||||
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent: 210 40% 96.1%;
|
||||
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--ring: hsl(222.2 84% 4.9%);
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background-hsl: hsl(222.2 84% 4.9%);
|
||||
--background: 222.2 84% 4.9%;
|
||||
|
||||
--foreground: hsl(210 40% 98%);
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: hsl(222.2 84% 4.9%);
|
||||
--card: 222.2 84% 4.9%;
|
||||
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover: 222.2 84% 4.9%;
|
||||
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: hsl(210 40% 98%);
|
||||
--primary: 210 40% 98%;
|
||||
|
||||
--primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: hsl(217.2 32.6% 17.5%);
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
|
||||
--secondary-foreground: hsl(210 40% 98%);
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: hsl(217.2 32.6% 17.5%);
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
|
||||
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: hsl(217.2 32.6% 17.5%);
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: hsl(217.2 32.6% 17.5%);
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
--ring: hsl(212.7 26.8% 83.9%);
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
61
web-new/themes/theme-gold.css
Normal file
@ -0,0 +1,61 @@
|
||||
.theme-gold.light {
|
||||
--background: 38, 77%, 88%;
|
||||
--foreground: 16, 43%, 34%;
|
||||
|
||||
--muted: 28, 46%, 56%;
|
||||
--muted-foreground: 16, 43%, 34%;
|
||||
|
||||
--popover: 38, 77%, 88%;
|
||||
--popover-foreground: 16, 43%, 34%;
|
||||
|
||||
--border: 28, 46%, 56%;
|
||||
--input: 28, 46%, 56%;
|
||||
|
||||
--card: 38, 77%, 88%;
|
||||
--card-foreground: 16, 43%, 34%;
|
||||
|
||||
--primary: 28, 46%, 56%;
|
||||
--primary-foreground: 60, 70%, 90%;
|
||||
|
||||
--secondary: 39, 100%, 73%;
|
||||
--secondary-foreground: 16, 43%, 34%;
|
||||
|
||||
--accent: 39, 100%, 73%;
|
||||
--accent-foreground: 16, 43%, 34%;
|
||||
|
||||
--destructive: 16, 43%, 34%;
|
||||
--destructive-foreground: 38, 77%, 88%;
|
||||
|
||||
--ring: 28, 46%, 56%;
|
||||
}
|
||||
|
||||
.theme-gold.dark {
|
||||
--background: 16, 43%, 34%;
|
||||
--foreground: 38, 77%, 88%;
|
||||
|
||||
--muted: 28, 46%, 56%;
|
||||
--muted-foreground: 38, 77%, 88%;
|
||||
|
||||
--popover: 16, 43%, 34%;
|
||||
--popover-foreground: 38, 77%, 88%;
|
||||
|
||||
--border: 28, 46%, 56%;
|
||||
--input: 28, 46%, 56%;
|
||||
|
||||
--card: 16, 43%, 34%;
|
||||
--card-foreground: 38, 77%, 88%;
|
||||
|
||||
--primary: 28, 46%, 56%;
|
||||
--primary-foreground: 60, 70%, 90%;
|
||||
|
||||
--secondary: 39, 100%, 73%;
|
||||
--secondary-foreground: 16, 43%, 34%;
|
||||
|
||||
--accent: 39, 100%, 52%;
|
||||
--accent-foreground: 16, 43%, 34%;
|
||||
|
||||
--destructive: 38, 77%, 88%;
|
||||
--destructive-foreground: 16, 43%, 34%;
|
||||
|
||||
--ring: 28, 46%, 56%;
|
||||
}
|
46
web-new/themes/theme-green.css
Normal file
@ -0,0 +1,46 @@
|
||||
@layer base {
|
||||
.theme-green.light {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 142.1 76.2% 36.3%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 142.1 76.2% 36.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-green.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 24 9.8% 10%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
--primary: 142.1 70.6% 45.3%;
|
||||
--primary-foreground: 144.9 80.4% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 142.4 71.8% 29.2%;
|
||||
}
|
||||
}
|
61
web-new/themes/theme-nature.css
Normal file
@ -0,0 +1,61 @@
|
||||
.theme-nature.light {
|
||||
--background: 58, 65%, 83%;
|
||||
--foreground: 187, 33%, 43%;
|
||||
|
||||
--muted: 70, 20%, 68%;
|
||||
--muted-foreground: 187, 33%, 43%;
|
||||
|
||||
--popover: 58, 65%, 83%;
|
||||
--popover-foreground: 187, 33%, 43%;
|
||||
|
||||
--border: 93, 28%, 56%;
|
||||
--input: 93, 28%, 56%;
|
||||
|
||||
--card: 58, 65%, 83%;
|
||||
--card-foreground: 187, 33%, 43%;
|
||||
|
||||
--primary: 93, 28%, 56%;
|
||||
--primary-foreground: 58, 65%, 83%;
|
||||
|
||||
--secondary: 70, 20%, 68%;
|
||||
--secondary-foreground: 187, 33%, 43%;
|
||||
|
||||
--accent: 70, 20%, 68%;
|
||||
--accent-foreground: 187, 33%, 43%;
|
||||
|
||||
--destructive: 187, 33%, 43%;
|
||||
--destructive-foreground: 58, 65%, 83%;
|
||||
|
||||
--ring: 93, 28%, 56%;
|
||||
}
|
||||
|
||||
.theme-nature.dark {
|
||||
--background: 187, 33%, 43%;
|
||||
--foreground: 58, 65%, 83%;
|
||||
|
||||
--muted: 70, 20%, 68%;
|
||||
--muted-foreground: 58, 65%, 83%;
|
||||
|
||||
--popover: 187, 33%, 43%;
|
||||
--popover-foreground: 58, 65%, 83%;
|
||||
|
||||
--border: 93, 28%, 56%;
|
||||
--input: 93, 28%, 56%;
|
||||
|
||||
--card: 187, 33%, 43%;
|
||||
--card-foreground: 58, 65%, 83%;
|
||||
|
||||
--primary: 93, 28%, 56%;
|
||||
--primary-foreground: 187, 33%, 43%;
|
||||
|
||||
--secondary: 70, 20%, 68%;
|
||||
--secondary-foreground: 58, 65%, 83%;
|
||||
|
||||
--accent: 70, 20%, 68%;
|
||||
--accent-foreground: 58, 65%, 83%;
|
||||
|
||||
--destructive: 58, 65%, 83%;
|
||||
--destructive-foreground: 187, 33%, 43%;
|
||||
|
||||
--ring: 93, 28%, 56%;
|
||||
}
|
61
web-new/themes/theme-netflix.css
Normal file
@ -0,0 +1,61 @@
|
||||
.theme-netflix.light {
|
||||
--background: 0, 0%, 100%;
|
||||
--foreground: 0, 0%, 0%;
|
||||
|
||||
--muted: 0, 0%, 68%;
|
||||
--muted-foreground: 0, 0%, 0%;
|
||||
|
||||
--popover: 0, 0%, 95%;
|
||||
--popover-foreground: 0, 0%, 0%;
|
||||
|
||||
--border: 0, 68%, 39%;
|
||||
--input: 0, 68%, 39%;
|
||||
|
||||
--card: 0, 0%, 95%;
|
||||
--card-foreground: 0, 0%, 0%;
|
||||
|
||||
--primary: 0, 100%, 44%;
|
||||
--primary-foreground: 0, 0%, 100%;
|
||||
|
||||
--secondary: 0, 0%, 0%;
|
||||
--secondary-foreground: 0, 0%, 100%;
|
||||
|
||||
--accent: 0, 100%, 50%;
|
||||
--accent-foreground: 0, 0%, 0%;
|
||||
|
||||
--destructive: 0, 68%, 39%;
|
||||
--destructive-foreground: 0, 0%, 100%;
|
||||
|
||||
--ring: 0, 100%, 44%;
|
||||
}
|
||||
|
||||
.theme-netflix.dark {
|
||||
--background: 0, 0%, 0%;
|
||||
--foreground: 0, 0%, 100%;
|
||||
|
||||
--muted: 0, 0%, 50%;
|
||||
--muted-foreground: 0, 0%, 100%;
|
||||
|
||||
--popover: 0, 0%, 5%;
|
||||
--popover-foreground: 0, 0%, 100%;
|
||||
|
||||
--border: 0, 68%, 39%;
|
||||
--input: 0, 68%, 39%;
|
||||
|
||||
--card: 0, 0%, 5%;
|
||||
--card-foreground: 0, 0%, 100%;
|
||||
|
||||
--primary: 0, 100%, 44%;
|
||||
--primary-foreground: 0, 0%, 0%;
|
||||
|
||||
--secondary: 0, 0%, 100%;
|
||||
--secondary-foreground: 0, 0%, 0%;
|
||||
|
||||
--accent: 0, 100%, 50%;
|
||||
--accent-foreground: 0, 0%, 100%;
|
||||
|
||||
--destructive: 0, 68%, 39%;
|
||||
--destructive-foreground: 0, 0%, 100%;
|
||||
|
||||
--ring: 0, 100%, 44%;
|
||||
}
|
61
web-new/themes/theme-nord.css
Normal file
@ -0,0 +1,61 @@
|
||||
.theme-nord.light {
|
||||
--background: 220, 16%, 96%;
|
||||
--foreground: 222, 20%, 16%;
|
||||
|
||||
--muted: 215, 14%, 80%;
|
||||
--muted-foreground: 222, 20%, 16%;
|
||||
|
||||
--popover: 220, 16%, 96%;
|
||||
--popover-foreground: 222, 20%, 16%;
|
||||
|
||||
--border: 222, 20%, 70%;
|
||||
--input: 222, 20%, 70%;
|
||||
|
||||
--card: 220, 16%, 96%;
|
||||
--card-foreground: 222, 20%, 16%;
|
||||
|
||||
--primary: 222, 20%, 70%;
|
||||
--primary-foreground: 220, 16%, 96%;
|
||||
|
||||
--secondary: 215, 14%, 80%;
|
||||
--secondary-foreground: 222, 20%, 16%;
|
||||
|
||||
--accent: 215, 14%, 80%;
|
||||
--accent-foreground: 222, 20%, 16%;
|
||||
|
||||
--destructive: 0, 80%, 50%;
|
||||
--destructive-foreground: 222, 20%, 16%;
|
||||
|
||||
--ring: 222, 20%, 70%;
|
||||
}
|
||||
|
||||
.theme-nord.dark {
|
||||
--background: 220, 16%, 16%;
|
||||
--foreground: 222, 20%, 96%;
|
||||
|
||||
--muted: 215, 14%, 25%;
|
||||
--muted-foreground: 222, 20%, 96%;
|
||||
|
||||
--popover: 220, 16%, 16%;
|
||||
--popover-foreground: 222, 20%, 96%;
|
||||
|
||||
--border: 222, 20%, 40%;
|
||||
--input: 222, 20%, 40%;
|
||||
|
||||
--card: 220, 16%, 16%;
|
||||
--card-foreground: 222, 20%, 96%;
|
||||
|
||||
--primary: 222, 20%, 65%;
|
||||
--primary-foreground: 220, 16%, 16%;
|
||||
|
||||
--secondary: 215, 14%, 25%;
|
||||
--secondary-foreground: 222, 20%, 96%;
|
||||
|
||||
--accent: 215, 14%, 25%;
|
||||
--accent-foreground: 222, 20%, 96%;
|
||||
|
||||
--destructive: 0, 80%, 50%;
|
||||
--destructive-foreground: 222, 20%, 96%;
|
||||
|
||||
--ring: 222, 20%, 40%;
|
||||
}
|
46
web-new/themes/theme-orange.css
Normal file
@ -0,0 +1,46 @@
|
||||
@layer base {
|
||||
.theme-orange.light {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--primary: 24.6 95% 53.1%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 24.6 95% 53.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-orange.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
--primary: 20.5 90.2% 48.2%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 20.5 90.2% 48.2%;
|
||||
}
|
||||
}
|
46
web-new/themes/theme-red.css
Normal file
@ -0,0 +1,46 @@
|
||||
@layer base {
|
||||
.theme-red.light {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 346.8 77.2% 49.8%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 346.8 77.2% 49.8%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-red.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 24 9.8% 10%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
--primary: 346.8 77.2% 49.8%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 346.8 77.2% 49.8%;
|
||||
}
|
||||
}
|
29
web-new/tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
web-new/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
61
web-new/vite.config.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/// <reference types="vitest" />
|
||||
import path from "path"
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.vitest': 'undefined',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
ws: true,
|
||||
},
|
||||
'/vod': {
|
||||
target: 'http://localhost:5000'
|
||||
},
|
||||
'/exports': {
|
||||
target: 'http://localhost:5000'
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:5000',
|
||||
ws: true,
|
||||
},
|
||||
'/live': {
|
||||
target: 'ws://localhost:5000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
monacoEditorPlugin.default({
|
||||
customWorkers: [{ label: 'yaml', entry: 'monaco-yaml/yaml.worker' }],
|
||||
languageWorkers: ['editorWorkerService'], // we don't use any of the default languages
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
alias: {
|
||||
'testing-library': path.resolve(__dirname, './__test__/testing-library.js'),
|
||||
},
|
||||
setupFiles: ['./__test__/test-setup.ts'],
|
||||
includeSource: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
coverage: {
|
||||
reporter: ['text-summary', 'text'],
|
||||
},
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
globals: true,
|
||||
},
|
||||
})
|