mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 02:28:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79ca18d439 | ||
|
|
cabdffea20 | ||
|
|
ba41c90c07 | ||
|
|
0c52a3175d | ||
|
|
4c648f8147 | ||
|
|
e0d4337a25 | ||
|
|
5e689f2d85 | ||
|
|
57a2765d00 | ||
|
|
dd77bae4f7 | ||
|
|
8a98d7c9b1 | ||
|
|
8a8da663c0 | ||
|
|
eddc9fccd1 | ||
|
|
84f981a77c | ||
|
|
4100383738 | ||
|
|
fa30a7e1ae |
@@ -46,9 +46,6 @@ jobs:
|
||||
- name: Build web
|
||||
run: npm run build
|
||||
working-directory: ./web
|
||||
# - name: Test
|
||||
# run: npm run test
|
||||
# working-directory: ./web
|
||||
|
||||
web_e2e:
|
||||
name: Web - E2E Tests
|
||||
|
||||
@@ -5,7 +5,7 @@ aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.46.*
|
||||
uvicorn == 0.52.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.6.*
|
||||
cryptography == 46.0.*
|
||||
@@ -61,7 +61,7 @@ rapidfuzz==3.12.*
|
||||
argcomplete==2.0.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
netaddr==0.8.*
|
||||
netaddr==1.3.*
|
||||
netifaces==0.10.*
|
||||
prometheus-client == 0.26.*
|
||||
# TFLite
|
||||
@@ -73,4 +73,4 @@ faster-whisper==1.2.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
memray == 1.15.*
|
||||
memray == 1.20.*
|
||||
|
||||
@@ -343,13 +343,6 @@ http {
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /locales/ {
|
||||
access_log off;
|
||||
include security_headers.conf;
|
||||
|
||||
@@ -204,11 +204,20 @@ Light guidelines and advice:
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
|
||||
- If you run into error messages like "TypeError: Cannot read properties of undefined (reading 'context')" when running tests, this may be due to these issues (https://github.com/vitest-dev/vitest/issues/1910, https://github.com/vitest-dev/vitest/issues/1652) in vitest, but I haven't been able to resolve them.
|
||||
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
||||
|
||||
```console
|
||||
npm run test
|
||||
# First-time setup
|
||||
npx playwright install chromium
|
||||
|
||||
# Build the app and run all tests
|
||||
npm run e2e:build && npm run e2e
|
||||
```
|
||||
|
||||
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
|
||||
|
||||
@@ -786,6 +786,13 @@ def auth(request: Request):
|
||||
|
||||
user = token.claims.get("sub")
|
||||
role = token.claims.get("role")
|
||||
|
||||
# the token keeps the role it was issued with, so a role removed from
|
||||
# the config since then must send the user back through login
|
||||
if role not in auth_config.roles:
|
||||
logger.debug("jwt role %s is not in the config", role)
|
||||
return fail_response
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
# if the jwt is expired
|
||||
|
||||
+14
-4
@@ -1038,6 +1038,10 @@ def export_recording_custom(
|
||||
if camera_validation_error is not None:
|
||||
return camera_validation_error
|
||||
|
||||
# Validate user-provided ffmpeg args to prevent injection and add to cases.
|
||||
# Admin users are trusted and skip validation.
|
||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
@@ -1048,6 +1052,16 @@ def export_recording_custom(
|
||||
cpu_fallback = body.cpu_fallback
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
|
||||
if export_case_id is not None and not is_admin:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Only admins can attach exports to an existing case.",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
case_validation_error = _validate_export_case(export_case_id)
|
||||
if case_validation_error is not None:
|
||||
return case_validation_error
|
||||
@@ -1064,10 +1078,6 @@ def export_recording_custom(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate user-provided ffmpeg args to prevent injection.
|
||||
# Admin users are trusted and skip validation.
|
||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||
|
||||
if not is_admin:
|
||||
for args_label, args_value in [
|
||||
("input", ffmpeg_input_args),
|
||||
|
||||
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
|
||||
)
|
||||
result = future.result()
|
||||
result = await asyncio.wrap_future(future)
|
||||
return JSONResponse(content=result)
|
||||
else:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -412,11 +412,8 @@ async def no_recordings(
|
||||
if not camera_list:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
params.after
|
||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
||||
)
|
||||
before = params.before or datetime.now().timestamp()
|
||||
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
|
||||
scale = params.scale
|
||||
|
||||
recordings: list[tuple[float, float]] = []
|
||||
|
||||
@@ -66,6 +66,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
|
||||
)
|
||||
|
||||
|
||||
def is_camera_preview_frame(file_name: str, camera: str) -> bool:
|
||||
"""Check whether a cached preview frame file belongs to the camera."""
|
||||
# camera names may contain "-", so a prefix match would include "front-door"
|
||||
return file_name.rsplit("-", 1)[0] == f"preview_{camera}"
|
||||
|
||||
|
||||
def get_most_recent_preview_frame(
|
||||
camera: str, before: float | None = None
|
||||
) -> str | None:
|
||||
@@ -79,7 +85,7 @@ def get_most_recent_preview_frame(
|
||||
preview_files = [
|
||||
f
|
||||
for f in os.listdir(PREVIEW_CACHE_DIR)
|
||||
if f.startswith(f"preview_{camera}-")
|
||||
if is_camera_preview_frame(f, camera)
|
||||
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
|
||||
]
|
||||
|
||||
@@ -276,7 +282,7 @@ class PreviewRecorder:
|
||||
start_file = f"{file_start}{start_ts}.webp"
|
||||
|
||||
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
||||
if not file.startswith(file_start):
|
||||
if not is_camera_preview_frame(file, self.camera_name):
|
||||
continue
|
||||
|
||||
if file < start_file:
|
||||
|
||||
@@ -36,6 +36,7 @@ from frigate.ffmpeg_presets import (
|
||||
parse_preset_hardware_acceleration_encode,
|
||||
)
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.output.preview import is_camera_preview_frame
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
@@ -1098,7 +1099,7 @@ class RecordingExporter(threading.Thread):
|
||||
fallback_preview = None
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
if not is_camera_preview_frame(file, self.camera):
|
||||
continue
|
||||
|
||||
if file < start_file:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests that /auth only accepts a JWT whose role is still in the config."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from frigate.api.auth import create_encoded_jwt
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestAuthJwtRole(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
||||
"networking": {"listen": {"internal": 5000, "external": 8971}},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def _create_app(self):
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
return create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
def _auth(self, app, role: str):
|
||||
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
return client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"x-server-port": "8971",
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
|
||||
def test_configured_role_is_accepted(self):
|
||||
resp = self._auth(self._create_app(), "garage")
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-user"], "bob")
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_role_removed_from_config_is_rejected(self):
|
||||
resp = self._auth(self._create_app(), "removed_role")
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertNotIn("remote-role", resp.headers)
|
||||
@@ -75,6 +75,21 @@ class TestPreviewLoader(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(get_most_recent_preview_frame(camera))
|
||||
|
||||
def test_get_most_recent_preview_frame_hyphenated_camera(self):
|
||||
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
|
||||
with open(
|
||||
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
|
||||
) as f:
|
||||
f.write("test")
|
||||
|
||||
expected_path = os.path.join(
|
||||
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
|
||||
)
|
||||
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
|
||||
self.assertEqual(
|
||||
get_most_recent_preview_frame("front", before=5000.0), expected_path
|
||||
)
|
||||
|
||||
def test_get_most_recent_preview_frame_no_directory(self):
|
||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
||||
|
||||
Generated
+790
-4301
File diff suppressed because it is too large
Load Diff
+56
-69
@@ -12,8 +12,6 @@
|
||||
"lint:fix": "eslint --fix .",
|
||||
"preview": "vite preview",
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"e2e:build": "tsc && vite build --base=/",
|
||||
"e2e": "playwright test --config e2e/playwright.config.ts",
|
||||
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
|
||||
@@ -24,119 +22,108 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@melloware/react-logviewer": "^6.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
"@radix-ui/react-context-menu": "^2.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-hover-card": "^1.1.23",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-toggle": "^1.1.18",
|
||||
"@radix-ui/react-toggle-group": "^1.1.19",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/shadcn": "^6.10.0",
|
||||
"@rjsf/utils": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"apexcharts": "^7.3.0",
|
||||
"axios": "^1.18.0",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^13.2.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"framer-motion": "^13.3.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"konva": "^10.2.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.4.2",
|
||||
"konva": "^10.5.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"lucide-react": "^1.46.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.2.4",
|
||||
"react": "^19.3.0",
|
||||
"react-apexcharts": "^2.1.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-dropzone": "^20.1.2",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "^17.0.14",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.30.6",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.4.1",
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.51.3",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/node": "^25.9.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"autoprefixer": "^10.6.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.6",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
|
||||
@@ -92,10 +92,12 @@ function PagePanel({ failure }: PanelProps) {
|
||||
|
||||
const stale = isStaleAsset(failure);
|
||||
|
||||
const onCopy = () => {
|
||||
// copy() falls back to a prompt and returns false when the clipboard is
|
||||
const onCopy = async () => {
|
||||
// copy() falls back to a prompt and resolves false when the clipboard is
|
||||
// refused, so a success toast has to wait on the result.
|
||||
const copied = copy(crashReport(failure, stats?.service.version));
|
||||
const copied = await copy(crashReport(failure, stats?.service.version), {
|
||||
fallbackToPrompt: true,
|
||||
});
|
||||
|
||||
if (copied) {
|
||||
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import StatusBarNotices from "@/components/health/StatusBarNotices";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
|
||||
|
||||
interface CameraNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
interface CameraNameLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof LabelPrimitive.Root
|
||||
> {
|
||||
camera?: string | CameraConfig;
|
||||
}
|
||||
|
||||
interface ZoneNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
interface ZoneNameLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof LabelPrimitive.Root
|
||||
> {
|
||||
zone: string;
|
||||
camera?: string;
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ export function MessageBubble({
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = content?.trim() || "";
|
||||
if (!text) return;
|
||||
if (copy(text)) {
|
||||
if (await copy(text)) {
|
||||
setCopied(true);
|
||||
toast.success(t("button.copiedToClipboard", { ns: "common" }));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
|
||||
@@ -163,8 +163,18 @@ export default function ClassificationModelEditDialog({
|
||||
}
|
||||
}, [isObjectModel, t]);
|
||||
|
||||
const form = useForm<ObjectFormData | StateFormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm<
|
||||
ObjectFormData | StateFormData,
|
||||
unknown,
|
||||
ObjectFormData | StateFormData
|
||||
>({
|
||||
resolver: zodResolver(
|
||||
formSchema as z.ZodType<
|
||||
ObjectFormData | StateFormData,
|
||||
z.ZodTypeDef,
|
||||
ObjectFormData | StateFormData
|
||||
>,
|
||||
),
|
||||
defaultValues: isObjectModel
|
||||
? ({
|
||||
enabled: model.enabled,
|
||||
|
||||
@@ -68,8 +68,7 @@ const review: SectionConfigOverrides = {
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const genai = ctx.formData?.genai as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
if (genai?.image_source !== "recordings") return false;
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.record?.enabled === false;
|
||||
|
||||
@@ -17,11 +17,9 @@ export function validateDetectDimensions(
|
||||
const height = data.height;
|
||||
|
||||
const widthErrors = errors.width as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
{ addError?: (message: string) => void } | undefined;
|
||||
const heightErrors = errors.height as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
{ addError?: (message: string) => void } | undefined;
|
||||
|
||||
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ export default function CameraReviewClassification({
|
||||
const cameraName = formContext?.cameraName ?? selectedCamera;
|
||||
const fullFormData = formContext?.formData as JsonObject | undefined;
|
||||
const baselineFormData = formContext?.baselineFormData as
|
||||
| JsonObject
|
||||
| undefined;
|
||||
JsonObject | undefined;
|
||||
const cameraConfig = formContext?.fullCameraConfig;
|
||||
|
||||
const alertsZones = useMemo(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@@ -371,9 +371,9 @@ export default function NotificationsSettingsExtras({
|
||||
|
||||
const shouldFetchPubKey = Boolean(
|
||||
config &&
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
);
|
||||
|
||||
const { data: publicKey } = useSWR(
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FieldPathList, FieldProps } from "@rjsf/utils";
|
||||
import yaml from "js-yaml";
|
||||
import { dump, load, YAMLException } from "js-yaml";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -16,7 +16,7 @@ function formatYaml(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
|
||||
return dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function parseYaml(text: string): {
|
||||
return { value: {}, error: undefined };
|
||||
}
|
||||
try {
|
||||
const parsed = yaml.load(trimmed);
|
||||
const parsed = load(trimmed);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
@@ -41,7 +41,7 @@ function parseYaml(text: string): {
|
||||
}
|
||||
return { value: parsed as Record<string, unknown>, error: undefined };
|
||||
} catch (e) {
|
||||
const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML";
|
||||
const msg = e instanceof YAMLException ? e.reason : "Invalid YAML";
|
||||
return { value: undefined, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +217,7 @@ function PlateCombobox({
|
||||
export function KnownPlatesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -159,8 +159,7 @@ function StreamValueCombobox({
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -96,8 +96,7 @@ const getItemProperties = (
|
||||
|
||||
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
|
||||
const scene = getItemProperties(itemSchema).scene as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const values = scene?.enum;
|
||||
|
||||
return Array.isArray(values)
|
||||
|
||||
@@ -44,8 +44,7 @@ function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
|
||||
export function ReplaceRulesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -86,8 +86,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
const { t, i18n } = useTranslation([
|
||||
|
||||
@@ -25,8 +25,7 @@ export function MultiSchemaFieldTemplate<
|
||||
const { schema, selector, optionSchemaField, uiSchema } = props;
|
||||
|
||||
const uiOptions = uiSchema?.["ui:options"] as
|
||||
| UiSchema["ui:options"]
|
||||
| undefined;
|
||||
UiSchema["ui:options"] | undefined;
|
||||
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
|
||||
|
||||
// Check if this is a simple nullable field that should be handled specially
|
||||
|
||||
@@ -55,8 +55,7 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
const [showCredentials, setShowCredentials] = useState(false);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const cameraName = formContext?.cameraName;
|
||||
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
|
||||
|
||||
@@ -103,8 +103,7 @@ const normalizeManualText = (value: unknown): string => {
|
||||
|
||||
export function FfmpegArgsWidget(props: WidgetProps) {
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
|
||||
@@ -27,8 +27,7 @@ import type { GenAIModelsResponse } from "@/types/chat";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
type ProbeResponse =
|
||||
| { success: true; models: string[] }
|
||||
| { success: false; message: string };
|
||||
{ success: true; models: string[] } | { success: false; message: string };
|
||||
|
||||
type ProbeStatus = "idle" | "probing" | "success" | "error";
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ export function OnvifProfileWidget(props: WidgetProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
const cameraName = formContext?.cameraName;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
|
||||
|
||||
@@ -98,8 +98,7 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
const availableEntities = useMemo(() => {
|
||||
const getEntities =
|
||||
(props.options?.getEntities as
|
||||
| ((context: FormContext) => string[])
|
||||
| undefined) || (() => []);
|
||||
((context: FormContext) => string[]) | undefined) || (() => []);
|
||||
if (context) {
|
||||
return getEntities(context);
|
||||
}
|
||||
@@ -109,8 +108,8 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
const getDisplayLabel = useMemo(
|
||||
() =>
|
||||
(props.options?.getDisplayLabel as
|
||||
| ((entity: string, context?: FormContext) => string)
|
||||
| undefined) || ((entity: string) => entity),
|
||||
((entity: string, context?: FormContext) => string) | undefined) ||
|
||||
((entity: string) => entity),
|
||||
[props.options],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useRef, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { ScrollFollow } from "@melloware/react-logviewer";
|
||||
|
||||
export type ScrollFollowProps = {
|
||||
startFollowing?: boolean;
|
||||
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
|
||||
onCustomScroll?: (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type ScrollFollowRenderProps = {
|
||||
follow: boolean;
|
||||
onScroll: (args: {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}) => void;
|
||||
startFollowing: () => void;
|
||||
stopFollowing: () => void;
|
||||
onCustomScroll?: (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const SCROLL_BUFFER = 5;
|
||||
|
||||
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
|
||||
const followRef = useRef(props.startFollowing || false);
|
||||
const prevScrollTopRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
prevScrollTopRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
const wrappedRender = useCallback(
|
||||
(renderProps: ScrollFollowRenderProps) => {
|
||||
const wrappedOnScroll = (args: {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}) => {
|
||||
// Check if scrolling up and immediately stop following
|
||||
if (
|
||||
prevScrollTopRef.current !== undefined &&
|
||||
args.scrollTop < prevScrollTopRef.current
|
||||
) {
|
||||
if (followRef.current) {
|
||||
renderProps.stopFollowing();
|
||||
followRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const bottomThreshold =
|
||||
args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
|
||||
const isNearBottom = args.scrollTop >= bottomThreshold;
|
||||
|
||||
if (isNearBottom && !followRef.current) {
|
||||
renderProps.startFollowing();
|
||||
followRef.current = true;
|
||||
} else if (!isNearBottom && followRef.current) {
|
||||
renderProps.stopFollowing();
|
||||
followRef.current = false;
|
||||
}
|
||||
|
||||
prevScrollTopRef.current = args.scrollTop;
|
||||
renderProps.onScroll(args);
|
||||
if (props.onCustomScroll) {
|
||||
props.onCustomScroll(
|
||||
args.scrollTop,
|
||||
args.scrollHeight,
|
||||
args.clientHeight,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return props.render({
|
||||
...renderProps,
|
||||
onScroll: wrappedOnScroll,
|
||||
follow: followRef.current,
|
||||
});
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
return <ScrollFollow {...props} render={wrappedRender} />;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MdHome } from "react-icons/md";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -66,7 +67,8 @@ import { z } from "zod";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { deleteUserNamespacedKey } from "@/hooks/use-user-persistence";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
@@ -499,9 +501,8 @@ function NewGroupDialog({
|
||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [, , , deleteGridLayout] = useUserPersistence(
|
||||
`${activeGroup}-draggable-layout`,
|
||||
);
|
||||
const { auth } = useContext(AuthContext);
|
||||
const username = auth?.user?.username;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -513,15 +514,14 @@ function NewGroupDialog({
|
||||
|
||||
const onDeleteGroup = useCallback(
|
||||
async (name: string) => {
|
||||
deleteGridLayout();
|
||||
deleteGroup();
|
||||
|
||||
await axios
|
||||
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
deleteUserNamespacedKey(`${name}-draggable-layout`, username);
|
||||
if (activeGroup == name) {
|
||||
// deleting current group
|
||||
deleteGroup();
|
||||
setGroup("default");
|
||||
}
|
||||
updateConfig();
|
||||
@@ -557,15 +557,7 @@ function NewGroupDialog({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[
|
||||
updateConfig,
|
||||
activeGroup,
|
||||
setGroup,
|
||||
setOpen,
|
||||
deleteGroup,
|
||||
deleteGridLayout,
|
||||
t,
|
||||
],
|
||||
[updateConfig, activeGroup, setGroup, setOpen, deleteGroup, username, t],
|
||||
);
|
||||
|
||||
const onSave = () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import useNavigation from "@/hooks/use-navigation";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
} from "@/context/statusbar-context";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export abstract class PreviewController {
|
||||
public camera = "";
|
||||
|
||||
constructor(camera: string) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
abstract scrubToTimestamp(time: number): boolean;
|
||||
|
||||
abstract finishedSeeking(): void;
|
||||
|
||||
abstract setNewPreviewStartTime(time: number): void;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/hooks/use-camera-previews";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { PreviewController } from "./PreviewController";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
previewRef?: (ref: HTMLDivElement | null) => void;
|
||||
@@ -102,20 +103,6 @@ export default function PreviewPlayer({
|
||||
);
|
||||
}
|
||||
|
||||
export abstract class PreviewController {
|
||||
public camera = "";
|
||||
|
||||
constructor(camera: string) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
abstract scrubToTimestamp(time: number): boolean;
|
||||
|
||||
abstract finishedSeeking(): void;
|
||||
|
||||
abstract setNewPreviewStartTime(time: number): void;
|
||||
}
|
||||
|
||||
type PreviewVideoPlayerProps = {
|
||||
visibilityRef?: (ref: HTMLDivElement | null) => void;
|
||||
className?: string;
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
*/
|
||||
|
||||
export type DownswitchReason =
|
||||
| "stall"
|
||||
| "bandwidth"
|
||||
| "fatal-error"
|
||||
| "startup"
|
||||
| "codec";
|
||||
"stall" | "bandwidth" | "fatal-error" | "startup" | "codec";
|
||||
|
||||
// stalls just after a seek are expected on any network (the target
|
||||
// position is rarely buffered), so they get a longer budget and are
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Recording } from "@/types/record";
|
||||
import { DynamicPlayback } from "@/types/playback";
|
||||
import { PreviewController } from "../PreviewPlayer";
|
||||
import { PreviewController } from "../PreviewController";
|
||||
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
||||
import {
|
||||
calculateInpointOffset,
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
RecordingCoverage,
|
||||
} from "@/types/record";
|
||||
import { Preview } from "@/types/preview";
|
||||
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
||||
import PreviewPlayer from "../PreviewPlayer";
|
||||
import { PreviewController } from "../PreviewController";
|
||||
import { DynamicVideoController } from "./DynamicVideoController";
|
||||
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
import { buildConfigDataForPath } from "@/utils/configUtil";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
|
||||
@@ -203,7 +203,8 @@ export function PolygonCanvas({
|
||||
if (stage) {
|
||||
// we add an unfilled line for adding points when finished
|
||||
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
|
||||
let pos = [e.target._lastPos!.x, e.target._lastPos!.y];
|
||||
const dragged = e.target.getAbsolutePosition();
|
||||
let pos = [dragged.x, dragged.y];
|
||||
|
||||
if (snapPoints) {
|
||||
// Snap to other polygons' edges
|
||||
|
||||
@@ -316,7 +316,11 @@ export default function ZoneEditPane({
|
||||
return profileZone ?? cam.zones[polygon.name];
|
||||
}, [polygon, config, editingProfile]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
const form = useForm<
|
||||
z.input<typeof formSchema>,
|
||||
unknown,
|
||||
z.output<typeof formSchema>
|
||||
>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
@@ -601,7 +605,7 @@ export default function ZoneEditPane({
|
||||
],
|
||||
);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
function onSubmit(values: z.output<typeof formSchema>) {
|
||||
if (activePolygonIndex === undefined || !values || !polygons) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio }
|
||||
export { AspectRatio };
|
||||
|
||||
@@ -24,7 +24,8 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
|
||||
@@ -35,7 +35,8 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -11,8 +11,8 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -22,7 +22,7 @@ const Checkbox = React.forwardRef<
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -99,7 +99,11 @@ export default function AnimatedCircularProgressBar({
|
||||
</svg>
|
||||
<span
|
||||
data-current-value={currentPercent}
|
||||
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
|
||||
className="absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
|
||||
style={{
|
||||
animationDuration: "var(--transition-length)",
|
||||
animationDelay: "var(--delay)",
|
||||
}}
|
||||
>
|
||||
{currentPercent}%
|
||||
</span>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
@@ -45,12 +45,12 @@ const ContextMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
@@ -61,31 +61,31 @@ const ContextMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
@@ -94,8 +94,8 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
||||
<ContextMenuPrimitive.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 focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -107,9 +107,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
@@ -118,8 +118,8 @@ const ContextMenuRadioItem = React.forwardRef<
|
||||
<ContextMenuPrimitive.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 focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -130,13 +130,13 @@ const ContextMenuRadioItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
@@ -144,12 +144,12 @@ const ContextMenuLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
@@ -160,8 +160,8 @@ const ContextMenuSeparator = React.forwardRef<
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
@@ -171,13 +171,13 @@ const ContextMenuShortcut = ({
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
@@ -195,4 +195,4 @@ export {
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
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"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
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",
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -66,31 +66,31 @@ const DropdownMenuContent = React.forwardRef<
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
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",
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
@@ -99,8 +99,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<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
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -123,8 +123,8 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<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
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -135,13 +135,13 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -149,12 +149,12 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
@@ -165,8 +165,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
@@ -177,9 +177,9 @@ const DropdownMenuShortcut = ({
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
@@ -197,4 +197,4 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
@@ -17,11 +17,11 @@ const HoverCardContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
|
||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
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"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
@@ -18,7 +18,7 @@ const Label = React.forwardRef<
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
@@ -11,7 +11,7 @@ const Progress = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -20,7 +20,7 @@ const Progress = React.forwardRef<
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
@@ -14,9 +14,9 @@ const RadioGroup = React.forwardRef<
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
@@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
@@ -18,8 +18,8 @@ const ScrollArea = React.forwardRef<
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
@@ -34,13 +34,13 @@ const ScrollBar = React.forwardRef<
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -18,12 +18,12 @@ const Separator = React.forwardRef<
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -93,7 +93,8 @@ const sheetVariants = cva(
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -9,7 +9,7 @@ function Skeleton({
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -9,7 +9,7 @@ const Switch = React.forwardRef<
|
||||
>(({ 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-selected data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-selected data-[state=unchecked]:bg-input 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -13,16 +13,16 @@ const Table = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
@@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
@@ -44,12 +44,12 @@ const TableFooter = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
@@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
"border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
@@ -74,12 +74,12 @@ const TableHead = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
@@ -90,8 +90,8 @@ const TableCell = React.forwardRef<
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
@@ -102,8 +102,8 @@ const TableCaption = React.forwardRef<
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
@@ -114,4 +114,4 @@ export {
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
@@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
@@ -27,13 +27,13 @@ const TabsTrigger = React.forwardRef<
|
||||
<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
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
@@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextElements =
|
||||
| "p"
|
||||
| "blockquote"
|
||||
| "code"
|
||||
| "lead"
|
||||
| "large"
|
||||
| "small"
|
||||
| "muted";
|
||||
"p" | "blockquote" | "code" | "lead" | "large" | "small" | "muted";
|
||||
|
||||
const Text = ({
|
||||
children,
|
||||
@@ -36,7 +30,7 @@ const Text = ({
|
||||
<code
|
||||
className={cn(
|
||||
"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
@@ -11,14 +10,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
@@ -26,16 +26,16 @@ const ToggleGroup = React.forwardRef<
|
||||
{children}
|
||||
</ToggleGroupContext>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
@@ -45,15 +45,15 @@ const ToggleGroupItem = React.forwardRef<
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground 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=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
@@ -23,8 +23,8 @@ const toggleVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
@@ -36,8 +36,8 @@ const Toggle = React.forwardRef<
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
export { Toggle, toggleVariants };
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
@@ -18,11 +18,11 @@ const TooltipContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AuthState {
|
||||
user: { username: string; role: string | null } | null;
|
||||
allowedCameras: string[];
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean; // true if auth is required
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
auth: AuthState;
|
||||
login: (user: AuthState["user"]) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType>({
|
||||
auth: {
|
||||
user: null,
|
||||
allowedCameras: [],
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
},
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
@@ -1,30 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
interface AuthState {
|
||||
user: { username: string; role: string | null } | null;
|
||||
allowedCameras: string[];
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean; // true if auth is required
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
auth: AuthState;
|
||||
login: (user: AuthState["user"]) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType>({
|
||||
auth: {
|
||||
user: null,
|
||||
allowedCameras: [],
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
},
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
import { AuthContext, AuthState } from "./auth-context";
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [auth, setAuth] = useState<AuthState>({
|
||||
@@ -6,7 +6,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
|
||||
import { LanguageProvider } from "./language-provider";
|
||||
import { StreamingSettingsProvider } from "./streaming-settings-provider";
|
||||
import { AuthProvider } from "./auth-context";
|
||||
import { AuthProvider } from "./auth-provider";
|
||||
|
||||
type TProvidersProps = {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type StatusMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
export type StatusMessagesState = {
|
||||
[key: string]: StatusMessage[];
|
||||
};
|
||||
|
||||
type StatusBarMessagesContextValue = {
|
||||
messages: StatusMessagesState;
|
||||
addMessage: (
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => string | undefined;
|
||||
removeMessage: (key: string, messageId: string) => void;
|
||||
clearMessages: (key: string) => void;
|
||||
};
|
||||
|
||||
export const StatusBarMessagesContext =
|
||||
createContext<StatusBarMessagesContextValue | null>(null);
|
||||
@@ -1,42 +1,13 @@
|
||||
import { useState, ReactNode, useCallback, useMemo } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export type StatusMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
export type StatusMessagesState = {
|
||||
[key: string]: StatusMessage[];
|
||||
};
|
||||
StatusBarMessagesContext,
|
||||
StatusMessagesState,
|
||||
} from "@/context/statusbar-context";
|
||||
|
||||
type StatusBarMessagesProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type StatusBarMessagesContextValue = {
|
||||
messages: StatusMessagesState;
|
||||
addMessage: (
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => string | undefined;
|
||||
removeMessage: (key: string, messageId: string) => void;
|
||||
clearMessages: (key: string) => void;
|
||||
};
|
||||
|
||||
export const StatusBarMessagesContext =
|
||||
createContext<StatusBarMessagesContextValue | null>(null);
|
||||
|
||||
export function StatusBarMessagesProvider({
|
||||
children,
|
||||
}: StatusBarMessagesProviderProps) {
|
||||
|
||||
@@ -123,8 +123,7 @@ function getDefaultFilter(
|
||||
if (!schema) return undefined;
|
||||
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
|
||||
const filtersSchema = sectionSchema?.properties?.filters as
|
||||
| RJSFSchema
|
||||
| undefined;
|
||||
RJSFSchema | undefined;
|
||||
if (!filtersSchema) return undefined;
|
||||
|
||||
// An optional map (`dict[str, X] | None`, as `audio.filters` is declared)
|
||||
@@ -965,8 +964,7 @@ export function useProfileSectionDeltas(
|
||||
|
||||
const profileSection = (
|
||||
cameraConfig.profiles?.[profileName] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
Record<string, unknown> | undefined
|
||||
)?.[sectionPath];
|
||||
if (profileSection == null) return [];
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { payload: replayJob } = useJobStatus("debug_replay", isAdmin);
|
||||
const replayActive = Boolean(
|
||||
isAdmin &&
|
||||
replayJob &&
|
||||
(replayJob.status === "queued" ||
|
||||
replayJob.status === "running" ||
|
||||
replayJob.status === "success"),
|
||||
replayJob &&
|
||||
(replayJob.status === "queued" ||
|
||||
replayJob.status === "running" ||
|
||||
replayJob.status === "success"),
|
||||
);
|
||||
|
||||
const memoizedStats = useDeepMemo(stats);
|
||||
|
||||
+18
-23
@@ -48,126 +48,126 @@ html {
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Thin.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Thin.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-ThinItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-ThinItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-ExtraLight.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-ExtraLight.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-ExtraLightItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-ExtraLightItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Light.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Light.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-LightItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-LightItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Regular.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Italic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Italic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Medium.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Medium.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-MediumItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-MediumItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-SemiBold.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-SemiBoldItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-SemiBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Bold.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Bold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-BoldItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-BoldItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-ExtraBold.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-ExtraBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-ExtraBoldItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-ExtraBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-Black.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-Black.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("../fonts/Inter-BlackItalic.woff2") format("woff2");
|
||||
src: url("./assets/fonts/Inter-BlackItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
.react-resizable-handle {
|
||||
@@ -184,8 +184,3 @@ html {
|
||||
.react-grid-layout .react-grid-item {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.react-lazylog,
|
||||
.react-lazylog-searchbar {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -131,8 +131,7 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
|
||||
anyOf.length === stringBranches.length + (hasNull ? 1 : 0)
|
||||
) {
|
||||
const enumValues = (enumBranch as Record<string, unknown>).enum as
|
||||
| unknown[]
|
||||
| undefined;
|
||||
unknown[] | undefined;
|
||||
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
|
||||
return {
|
||||
...rest,
|
||||
@@ -751,8 +750,7 @@ export function applySchemaDefaults(
|
||||
let properties = schemaObj.properties;
|
||||
if (!isSchemaObject(properties)) {
|
||||
const branches = (schemaObj.anyOf ?? schemaObj.oneOf) as
|
||||
| unknown[]
|
||||
| undefined;
|
||||
unknown[] | undefined;
|
||||
if (Array.isArray(branches)) {
|
||||
const objectBranch = branches.find(
|
||||
(s) =>
|
||||
|
||||
@@ -123,7 +123,7 @@ function ConfigEditor() {
|
||||
hover: true,
|
||||
completion: true,
|
||||
validate: true,
|
||||
format: true,
|
||||
format: { enable: true },
|
||||
schemas: [
|
||||
{
|
||||
uri: `${apiHost}api/config/schema.json`,
|
||||
|
||||
+200
-168
@@ -8,7 +8,13 @@ import {
|
||||
logTypes,
|
||||
} from "@/types/log";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import axios from "axios";
|
||||
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
|
||||
import { LogChip } from "@/components/indicators/Chip";
|
||||
@@ -21,34 +27,110 @@ import { cn } from "@/lib/utils";
|
||||
import { parseLogLines } from "@/utils/logUtil";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import { LazyLog } from "@melloware/react-logviewer";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { debounce } from "lodash";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { isInIframe } from "@/utils/isIFrame";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||
|
||||
const OLDER_LINES_CHUNK_SIZE = 100;
|
||||
const FOLLOW_THRESHOLD_PX = 40;
|
||||
|
||||
// Desktop row height. Without it, virtua guesses 40px and the first render
|
||||
// leaves the viewport partly empty. Mobile rows are taller, and a low hint
|
||||
// there shrinks the scroll room iOS gets while it defers scroll correction
|
||||
const ROW_HEIGHT_HINT_PX = 29;
|
||||
|
||||
// Stable ids keep row measurements attached to the right line after a prepend
|
||||
type LogEntry = { id: number; text: string };
|
||||
|
||||
// shift anchors the viewport to the end when lines are prepended. stick keeps
|
||||
// the newest line in view when lines are appended
|
||||
type LogState = { entries: LogEntry[]; shift: boolean; stick: boolean };
|
||||
|
||||
function Logs() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
const isWebsocket = logService === "websocket";
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const logWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<VListHandle>(null);
|
||||
const [logState, setLogState] = useState<LogState>({
|
||||
entries: [],
|
||||
shift: false,
|
||||
stick: true,
|
||||
});
|
||||
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||
const lazyLogRef = useRef<LazyLog>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const lastFetchedIndexRef = useRef(-1);
|
||||
const loadingOlderRef = useRef(false);
|
||||
const firstIdRef = useRef(0);
|
||||
const lastIdRef = useRef(0);
|
||||
|
||||
// The last wheel or key scroll went up. The view can still sit at the
|
||||
// bottom for a moment, so position alone would keep following
|
||||
const scrolledUpRef = useRef(false);
|
||||
|
||||
// lines
|
||||
|
||||
const isFollowing = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list || scrolledUpRef.current) return false;
|
||||
|
||||
return (
|
||||
list.scrollSize - list.scrollOffset - list.viewportSize <
|
||||
FOLLOW_THRESHOLD_PX
|
||||
);
|
||||
}, []);
|
||||
|
||||
const resetLines = useCallback((lines: string[]) => {
|
||||
firstIdRef.current = 0;
|
||||
lastIdRef.current = lines.length;
|
||||
setLogState({
|
||||
entries: lines.map((text, id) => ({ id, text })),
|
||||
shift: false,
|
||||
stick: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const appendLines = useCallback(
|
||||
(lines: string[]) => {
|
||||
const entries = lines
|
||||
.filter((text) => text.trim())
|
||||
.map((text) => ({ id: lastIdRef.current++, text }));
|
||||
if (!entries.length) return;
|
||||
|
||||
const stick = isFollowing();
|
||||
setLogState((prev) => ({
|
||||
entries: [...prev.entries, ...entries],
|
||||
shift: false,
|
||||
stick,
|
||||
}));
|
||||
},
|
||||
[isFollowing],
|
||||
);
|
||||
|
||||
const prependLines = useCallback((lines: string[]) => {
|
||||
firstIdRef.current -= lines.length;
|
||||
const firstId = firstIdRef.current;
|
||||
const entries = lines.map((text, i) => ({ id: firstId + i, text }));
|
||||
|
||||
setLogState((prev) => ({
|
||||
entries: [...entries, ...prev.entries],
|
||||
shift: true,
|
||||
stick: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.logs." + logService);
|
||||
@@ -102,8 +184,7 @@ function Logs() {
|
||||
response.data &&
|
||||
Array.isArray(response.data.lines)
|
||||
) {
|
||||
const filteredLines = filterLines(response.data.lines);
|
||||
return filteredLines;
|
||||
return response.data.lines as string[];
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
@@ -115,26 +196,27 @@ function Logs() {
|
||||
},
|
||||
);
|
||||
}
|
||||
return [];
|
||||
return null;
|
||||
},
|
||||
[logService, filterLines, t],
|
||||
[logService, t],
|
||||
);
|
||||
|
||||
const fetchInitialLogs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`logs/${logService}`, {
|
||||
params: { start: filterSeverity ? 0 : -100 },
|
||||
params: { start: filterSeverity?.length ? 0 : -100 },
|
||||
});
|
||||
if (
|
||||
response.status === 200 &&
|
||||
response.data &&
|
||||
Array.isArray(response.data.lines)
|
||||
) {
|
||||
const filteredLines = filterLines(response.data.lines);
|
||||
setLogs(filteredLines);
|
||||
resetLines(filterLines(response.data.lines));
|
||||
|
||||
// A filtered load fetches the whole file, so nothing older remains
|
||||
lastFetchedIndexRef.current =
|
||||
response.data.totalLines - filteredLines.length;
|
||||
response.data.totalLines - response.data.lines.length;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
@@ -145,7 +227,7 @@ function Logs() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [logService, filterLines, filterSeverity, t]);
|
||||
}, [logService, filterLines, filterSeverity, resetLines, t]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -180,9 +262,7 @@ function Logs() {
|
||||
return filterSeverity.includes(parsedLine.severity);
|
||||
})
|
||||
: lines;
|
||||
if (filteredLines.length > 0) {
|
||||
lazyLogRef.current?.appendLines(filteredLines);
|
||||
}
|
||||
appendLines(filteredLines);
|
||||
}
|
||||
// Process next chunk
|
||||
return processStreamChunk(reader);
|
||||
@@ -215,17 +295,19 @@ function Logs() {
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [logService, filterSeverity, t]);
|
||||
}, [logService, filterSeverity, appendLines, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWebsocket) {
|
||||
setIsLoading(false);
|
||||
setLogs([]);
|
||||
resetLines([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLogs([]);
|
||||
setFollow(true);
|
||||
scrolledUpRef.current = false;
|
||||
resetLines([]);
|
||||
lastFetchedIndexRef.current = -1;
|
||||
fetchInitialLogs().then(() => {
|
||||
// Start streaming after initial load
|
||||
@@ -243,70 +325,52 @@ function Logs() {
|
||||
|
||||
// handlers
|
||||
|
||||
const prependLines = useCallback((newLines: string[]) => {
|
||||
if (!lazyLogRef.current) return;
|
||||
const loadOlderLines = useCallback(async () => {
|
||||
const end = lastFetchedIndexRef.current;
|
||||
if (loadingOlderRef.current || end <= 0) return;
|
||||
|
||||
const newLinesArray = newLines.map(
|
||||
(line) => new Uint8Array(new TextEncoder().encode(line + "\n")),
|
||||
);
|
||||
loadingOlderRef.current = true;
|
||||
const start = Math.max(0, end - OLDER_LINES_CHUNK_SIZE);
|
||||
const lines = await fetchLogRange(start, end);
|
||||
loadingOlderRef.current = false;
|
||||
|
||||
lazyLogRef.current.setState((prevState) => ({
|
||||
...prevState,
|
||||
lines: prevState.lines.unshift(...newLinesArray),
|
||||
count: prevState.count + newLines.length,
|
||||
}));
|
||||
}, []);
|
||||
// A service or filter change resets the index while the request is in flight
|
||||
if (!lines || lastFetchedIndexRef.current !== end) return;
|
||||
|
||||
// debounced
|
||||
const handleScroll = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
const scrollThreshold =
|
||||
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 10;
|
||||
const startIndex =
|
||||
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0;
|
||||
const endIndex =
|
||||
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0;
|
||||
const pageSize = endIndex - startIndex;
|
||||
if (
|
||||
scrollThreshold < pageSize + pageSize / 2 &&
|
||||
lastFetchedIndexRef.current > 0 &&
|
||||
!isLoading
|
||||
) {
|
||||
const nextEnd = lastFetchedIndexRef.current;
|
||||
const nextStart = Math.max(0, nextEnd - (pageSize || 100));
|
||||
setIsLoading(true);
|
||||
lastFetchedIndexRef.current = start;
|
||||
prependLines(lines);
|
||||
}, [fetchLogRange, prependLines]);
|
||||
|
||||
fetchLogRange(nextStart, nextEnd).then((newLines) => {
|
||||
if (newLines.length > 0) {
|
||||
prependLines(newLines);
|
||||
lastFetchedIndexRef.current = nextStart;
|
||||
// Runs on scroll events and on wheel or key input, because input at the top
|
||||
// or bottom edge doesn't scroll and fires no scroll event
|
||||
const syncScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
lazyLogRef.current?.listRef.current?.scrollTo(
|
||||
newLines.length *
|
||||
lazyLogRef.current?.listRef.current?.getItemSize(1),
|
||||
);
|
||||
}
|
||||
});
|
||||
setFollow(isFollowing());
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 50),
|
||||
[fetchLogRange, isLoading, prependLines],
|
||||
);
|
||||
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
if (logs.length) {
|
||||
fetchInitialLogs()
|
||||
.then(() => {
|
||||
copy(logs.join("\n"));
|
||||
toast.success(t("logs.copy.success"));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("logs.copy.error"));
|
||||
});
|
||||
if (list.scrollOffset < list.viewportSize) {
|
||||
loadOlderLines();
|
||||
}
|
||||
}, [logs, fetchInitialLogs, t]);
|
||||
}, [isFollowing, loadOlderLines]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isLoading || !logState.stick || !logState.entries.length) return;
|
||||
|
||||
listRef.current?.scrollToIndex(logState.entries.length - 1, {
|
||||
align: "end",
|
||||
});
|
||||
}, [isLoading, logState]);
|
||||
|
||||
const handleCopyLogs = useCallback(async () => {
|
||||
if (!logState.entries.length) return;
|
||||
|
||||
if (await copy(logState.entries.map((entry) => entry.text).join("\n"))) {
|
||||
toast.success(t("logs.copy.success"));
|
||||
} else {
|
||||
toast.error(t("logs.copy.error"));
|
||||
}
|
||||
}, [logState, t]);
|
||||
|
||||
const handleDownloadLogs = useCallback(() => {
|
||||
axios
|
||||
@@ -329,77 +393,34 @@ function Logs() {
|
||||
.catch(() => {});
|
||||
}, [logService]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowInfo: { lineNumber: number; rowIndex: number }) => {
|
||||
const clickedLine = parseLogLines(logService, [
|
||||
logs[rowInfo.rowIndex],
|
||||
])[0];
|
||||
setSelectedLog(clickedLine);
|
||||
},
|
||||
[logs, logService],
|
||||
);
|
||||
|
||||
// keyboard listener
|
||||
|
||||
useKeyboardListener(
|
||||
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
|
||||
(key, modifiers) => {
|
||||
if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
|
||||
const list = listRef.current;
|
||||
if (!key || !modifiers.down || !list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const container =
|
||||
lazyLogWrapperRef.current.querySelector(".react-lazylog");
|
||||
|
||||
const logLineHeight = container?.querySelector(".log-line")?.clientHeight;
|
||||
|
||||
if (!logLineHeight) {
|
||||
const rowHeight = list.getItemSize(list.findItemIndex(list.scrollOffset));
|
||||
if (!rowHeight) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const scrollAmount = key.includes("Page")
|
||||
? logLineHeight * 10
|
||||
: logLineHeight;
|
||||
const rows = key.includes("Page") ? 10 : 1;
|
||||
const direction = key.includes("Down") ? 1 : -1;
|
||||
container?.scrollBy({ top: scrollAmount * direction });
|
||||
scrolledUpRef.current = direction < 0;
|
||||
list.scrollBy(rowHeight * rows * direction);
|
||||
syncScrollState();
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// format lines
|
||||
|
||||
const lineBufferRef = useRef<string>("");
|
||||
|
||||
const formatPart = useCallback(
|
||||
(text: string) => {
|
||||
lineBufferRef.current += text;
|
||||
|
||||
if (text.endsWith("\n")) {
|
||||
const completeLine = lineBufferRef.current.trim();
|
||||
lineBufferRef.current = "";
|
||||
|
||||
if (completeLine) {
|
||||
const parsedLine = parseLogLines(logService, [completeLine])[0];
|
||||
return (
|
||||
<LogLineData
|
||||
line={parsedLine}
|
||||
logService={logService}
|
||||
onClickSeverity={() => setFilterSeverity([parsedLine.severity])}
|
||||
onSelect={() => setSelectedLog(parsedLine)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[logService, setFilterSeverity, setSelectedLog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCopy = (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (!lazyLogWrapperRef.current) return;
|
||||
if (!logWrapperRef.current) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
@@ -467,7 +488,7 @@ function Logs() {
|
||||
e.clipboardData?.setData("text/plain", copyText);
|
||||
};
|
||||
|
||||
const content = lazyLogWrapperRef.current;
|
||||
const content = logWrapperRef.current;
|
||||
content?.addEventListener("copy", handleCopy);
|
||||
return () => {
|
||||
content?.removeEventListener("copy", handleCopy);
|
||||
@@ -488,7 +509,6 @@ function Logs() {
|
||||
value={logService}
|
||||
onValueChange={(value: LogType) => {
|
||||
if (value) {
|
||||
setLogs([]);
|
||||
setFilterSeverity(undefined);
|
||||
setLogService(value);
|
||||
}
|
||||
@@ -582,44 +602,56 @@ function Logs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={lazyLogWrapperRef} className="size-full">
|
||||
<div
|
||||
ref={logWrapperRef}
|
||||
className="min-h-0 flex-1"
|
||||
onPointerDown={() => {
|
||||
scrolledUpRef.current = false;
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<EnhancedScrollFollow
|
||||
startFollowing={!isLoading}
|
||||
onCustomScroll={handleScroll}
|
||||
render={({ follow, onScroll }) => (
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<LazyLog
|
||||
ref={lazyLogRef}
|
||||
enableLineNumbers={false}
|
||||
selectableLines
|
||||
lineClassName="text-primary bg-background"
|
||||
highlightLineClassName="bg-primary/20"
|
||||
onRowClick={handleRowClick}
|
||||
formatPart={formatPart}
|
||||
text={logs.join("\n")}
|
||||
follow={follow}
|
||||
onScroll={onScroll}
|
||||
loadingComponent={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</>
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<VList
|
||||
ref={listRef}
|
||||
itemSize={isDesktop ? ROW_HEIGHT_HINT_PX : undefined}
|
||||
data={logState.entries}
|
||||
shift={logState.shift}
|
||||
onScroll={syncScrollState}
|
||||
onWheel={(e) => {
|
||||
if (!e.deltaY) return;
|
||||
|
||||
scrolledUpRef.current = e.deltaY < 0;
|
||||
syncScrollState();
|
||||
}}
|
||||
>
|
||||
{(entry) => {
|
||||
const line = parseLogLines(logService, [entry.text])[0];
|
||||
return (
|
||||
<LogLineData
|
||||
key={entry.id}
|
||||
line={line}
|
||||
logService={logService}
|
||||
onClickSeverity={() =>
|
||||
setFilterSeverity([line.severity])
|
||||
}
|
||||
onSelect={() => setSelectedLog(line)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</VList>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user